diff --git a/packages/agent-core-v2/src/_base/contribution/registry.ts b/packages/agent-core-v2/src/_base/contribution/registry.ts new file mode 100644 index 0000000000000000000000000000000000000000..9c3f2309295838ec7e33c13f36f351b4866e8834 --- /dev/null +++ b/packages/agent-core-v2/src/_base/contribution/registry.ts @@ -0,0 +1,55 @@ +import { Disposable, type IDisposable } from '../di/lifecycle'; +import { Emitter, type Event } from '../event'; + +export interface ContributionRegistration { + readonly sourceId: string; + readonly priority: number; + readonly contribution: T; +} + +export interface RegisterContributionOptions { + readonly priority?: number; +} + +export class ContributionRegistry extends Disposable { + private readonly registrations = new Map>(); + private readonly onDidChangeEmitter = this._register(new Emitter()); + readonly onDidChange: Event = this.onDidChangeEmitter.event; + + register( + sourceId: string, + contribution: T, + options?: RegisterContributionOptions, + ): IDisposable { + const registration: ContributionRegistration = { + sourceId, + priority: options?.priority ?? 0, + contribution, + }; + this.registrations.set(sourceId, registration); + this.onDidChangeEmitter.fire(sourceId); + let active = true; + return { + dispose: () => { + if (!active) return; + active = false; + if (this.registrations.get(sourceId) !== registration) return; + this.registrations.delete(sourceId); + this.onDidChangeEmitter.fire(sourceId); + }, + }; + } + + unregister(sourceId: string): void { + if (!this.registrations.delete(sourceId)) return; + this.onDidChangeEmitter.fire(sourceId); + } + + entries(): readonly ContributionRegistration[] { + return [...this.registrations.values()]; + } + + get(sourceId: string): ContributionRegistration | undefined { + return this.registrations.get(sourceId); + } +} diff --git a/packages/agent-core-v2/src/_base/di/cascadeEngine.ts b/packages/agent-core-v2/src/_base/di/cascadeEngine.ts new file mode 100644 index 0000000000000000000000000000000000000000..d8f32d32aaf3fbfa1196318bda25a5a656d2db9b --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/cascadeEngine.ts @@ -0,0 +1,798 @@ +import { onUnexpectedError } from '../errors/unexpectedError'; +import { Emitter, type Event } from '../event'; +import { isPromiseLike } from '../lifecycle/disposer'; +import type { SyncDescriptor } from './descriptors'; +import { + PairIndex, + type DependencyGraph, + type ScopedToken, +} from './dependencyGraph'; +import { CascadeConflictError } from './errors'; +import type { ServiceIdentifier } from './instantiation'; + +export type UnitState = 'Pending' | 'Activating' | 'Active' | 'Unloading' | 'Failed'; + +export type CascadeAction = 'provide' | 'unprovide' | 'update'; + +export type UnitActivation = 'eager' | 'ondemand'; + +export interface CascadeChange { + readonly action: CascadeAction; + readonly token: ServiceIdentifier; + readonly descriptor?: SyncDescriptor; + readonly instance?: unknown; + readonly activation?: UnitActivation; + readonly config?: unknown; + readonly reason: string; +} + +export interface CascadeHistoryEntry { + readonly seq: number; + readonly reason: string; + readonly changes: ReadonlyArray<{ token: string; action: CascadeAction }>; + readonly affected: readonly string[]; + readonly tornDown: readonly string[]; + readonly rebuilt: readonly string[]; + readonly failed: readonly string[]; + readonly abortWaited: boolean; + readonly abortTimedOut: boolean; + readonly durationMs: number; +} + +export interface UnitSnapshot { + readonly token: string; + readonly state: UnitState; + readonly error?: string; + readonly everActive: boolean; + readonly inFlight: boolean; +} + +export interface UnitStateChange { + readonly token: string; + readonly state: UnitState; + readonly error?: string; +} + +export interface CascadeEngineOptions { + onWillCascade?: ( + affected: readonly ScopedToken[], + reason: string, + ) => void | Promise; + readonly abortWaitMs?: number; + readonly resolveTimeoutMs?: number; + readonly historyCapacity?: number; + readonly now?: () => number; +} + +export interface CascadeHost { + isRegistered(token: ServiceIdentifier): boolean; + ownerScopeOf(token: ServiceIdentifier): object | undefined; + isMaterialized(token: ServiceIdentifier): boolean; + materialize(token: ServiceIdentifier): unknown; + retire(token: ServiceIdentifier): void | Promise; + applyProvide( + token: ServiceIdentifier, + descriptor: SyncDescriptor, + config: unknown, + ): number; + applyProvideInstance( + token: ServiceIdentifier, + instance: unknown, + config: unknown, + ): number; + applyUnprovide(token: ServiceIdentifier): void; + recipeOf(token: ServiceIdentifier): SyncDescriptor | undefined; + dependenciesOf( + recipe: SyncDescriptor, + ): Array>; +} + +export interface CascadeScopeHandle { + readonly cascade: CascadeEngine; + readonly cascadeDisposed: boolean; + readonly cascadeDepth: number; +} + +interface UnitRecord { + state: UnitState; + error?: unknown; + activation: UnitActivation; + everActive: boolean; +} + +interface QueuedRequest { + readonly engine: CascadeEngine; + readonly change: CascadeChange; + readonly resolve: () => void; + readonly reject: (error: unknown) => void; +} + +const DEFAULT_ABORT_WAIT_MS = 5000; +const DEFAULT_RESOLVE_TIMEOUT_MS = 30000; +const DEFAULT_HISTORY_CAPACITY = 200; + +export class CascadeTree { + readonly graph: DependencyGraph; + readonly queue: QueuedRequest[] = []; + readonly engines = new Set(); + running = false; + orchestrator: object | undefined; + private readonly _inFlight = new PairIndex(); + private _settleWaiters: Array<() => void> = []; + private readonly _scopeSeq = new Map(); + private _nextScopeSeq = 0; + private readonly _droppedScopes = new WeakSet(); + private readonly _onDidAddEngine = new Emitter(); + readonly onDidAddEngine: Event = this._onDidAddEngine.event; + private readonly _onDidRemoveEngine = new Emitter(); + readonly onDidRemoveEngine: Event = this._onDidRemoveEngine.event; + + constructor(graph: DependencyGraph) { + this.graph = graph; + } + + addEngine(engine: CascadeEngine): void { + this.engines.add(engine); + this._onDidAddEngine.fire(engine); + } + + removeEngine(engine: CascadeEngine): void { + if (this.engines.delete(engine)) { + this._onDidRemoveEngine.fire(engine); + } + } + + inFlightSet(ref: ScopedToken, on: boolean): void { + if (on) { + this._inFlight.set(ref.scope, ref.token, true); + } else { + this._inFlight.delete(ref.scope, ref.token); + } + } + + inFlightHas(ref: ScopedToken): boolean { + return this._inFlight.get(ref.scope, ref.token) !== undefined; + } + + inFlightClear(refs: Iterable): void { + for (const ref of refs) { + this._inFlight.delete(ref.scope, ref.token); + } + } + + seqOf(scope: object): number { + let seq = this._scopeSeq.get(scope); + if (seq === undefined) { + if (this._droppedScopes.has(scope)) { + return -1; + } + seq = this._nextScopeSeq++; + this._scopeSeq.set(scope, seq); + } + return seq; + } + + dropScope(scope: object): void { + this._droppedScopes.add(scope); + this._scopeSeq.delete(scope); + this._inFlight.deleteScope(scope); + } + + addSettleWaiter(waiter: () => void): void { + this._settleWaiters.push(waiter); + } + + fireSettleWaiters(): void { + const waiters = this._settleWaiters.splice(0); + for (const waiter of waiters) { + waiter(); + } + } +} + +export class CascadeEngine { + private readonly _units = new Map< + ServiceIdentifier, + UnitRecord + >(); + private readonly _pendingIndex = new Map< + ServiceIdentifier, + Set> + >(); + private readonly _history: CascadeHistoryEntry[] = []; + private _historySeq = 0; + private _disposed = false; + private _activationSuspended = 0; + private readonly _onDidChangeUnitState = new Emitter(); + readonly onDidChangeUnitState: Event = this._onDidChangeUnitState.event; + private readonly _onDidCascade = new Emitter(); + readonly onDidCascade: Event = this._onDidCascade.event; + + constructor( + private readonly _host: CascadeHost, + private readonly _scope: CascadeScopeHandle, + private readonly _tree: CascadeTree, + private _options: CascadeEngineOptions = {}, + ) { + this._tree.addEngine(this); + } + + configure(options: CascadeEngineOptions): void { + this._options = { ...this._options, ...options }; + } + + stateOf(token: ServiceIdentifier): UnitState | undefined { + return this._units.get(token)?.state; + } + + activationOf(token: ServiceIdentifier): UnitActivation | undefined { + return this._units.get(token)?.activation; + } + + materializable(token: ServiceIdentifier): boolean { + return this._host.recipeOf(token) !== undefined; + } + + failureOf(token: ServiceIdentifier): unknown { + const unit = this._units.get(token); + return unit?.state === 'Failed' ? unit.error : undefined; + } + + isInFlight(token: ServiceIdentifier): boolean { + const owner = this._host.ownerScopeOf(token) ?? this._scope; + return this._tree.inFlightHas({ scope: owner, token }); + } + + history(): readonly CascadeHistoryEntry[] { + return this._history; + } + + pendingSnapshot(): ReadonlyMap { + const snapshot = new Map(); + for (const [token, unit] of this._units) { + if (unit.state !== 'Pending') continue; + snapshot.set( + token.toString(), + this._missingDeps(token).map((dep) => dep.toString()), + ); + } + return snapshot; + } + + unitsSnapshot(): UnitSnapshot[] { + const snapshot: UnitSnapshot[] = []; + for (const [token, unit] of this._units) { + snapshot.push({ + token: token.toString(), + state: unit.state, + error: unit.error === undefined ? undefined : serializeError(unit.error), + everActive: unit.everActive, + inFlight: this.isInFlight(token), + }); + } + return snapshot; + } + + submit(change: CascadeChange): Promise { + if (this._disposed) { + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + this._tree.queue.push({ engine: this, change, resolve, reject }); + this._pump(); + }); + } + + submitAll(changes: readonly CascadeChange[]): Promise { + if (this._disposed || changes.length === 0) { + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + for (const change of changes) { + this._tree.queue.push({ engine: this, change, resolve, reject }); + } + this._pump(); + }); + } + + update(token: ServiceIdentifier, reason?: string): Promise { + return this.submit({ + action: 'update', + token, + reason: reason ?? `update ${String(token)}`, + }); + } + + whenIdle(): Promise { + if (!this._tree.running && this._tree.queue.length === 0) { + return Promise.resolve(); + } + return new Promise((resolve) => { + this._tree.addSettleWaiter(() => { + void this.whenIdle().then(resolve); + }); + }); + } + + suspendActivation(): void { + this._activationSuspended++; + } + + resumeActivation(): void { + if (this._activationSuspended === 0) { + return; + } + this._activationSuspended--; + if (this._activationSuspended > 0 || this._disposed) { + return; + } + const rebuilt: string[] = []; + const failed: string[] = []; + this._recheckTreeFixpoint(rebuilt, failed); + } + + private _recheckTreeFixpoint(rebuilt: string[], failed: string[]): void { + const enginesInOrder = [...this._tree.engines] + .filter((engine) => !engine._disposed) + .toSorted((a, b) => a._scope.cascadeDepth - b._scope.cascadeDepth); + for (;;) { + let progress = false; + for (const engine of enginesInOrder) { + const before = rebuilt.length + failed.length; + engine._recheckForCascade(rebuilt, failed); + if (rebuilt.length + failed.length > before) { + progress = true; + } + } + if (!progress) { + break; + } + } + } + + resolveWhenAvailable( + token: ServiceIdentifier, + timeoutMs?: number, + ): Promise { + if (!this.isInFlight(token)) { + try { + return Promise.resolve(this._host.materialize(token) as T); + } catch (error) { + return Promise.reject(error); + } + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject( + new CascadeConflictError( + String(token), + 'timed out waiting for the in-flight cascade to settle', + ), + ); + }, timeoutMs ?? this._options.resolveTimeoutMs ?? DEFAULT_RESOLVE_TIMEOUT_MS); + this._tree.addSettleWaiter(() => { + clearTimeout(timer); + try { + resolve(this._host.materialize(token) as T); + } catch (error) { + reject(error); + } + }); + }); + } + + observedMaterialization(token: ServiceIdentifier): void { + const unit = this._units.get(token); + if (unit !== undefined && unit.state === 'Pending') { + unit.everActive = true; + this._setUnitState(token, unit, 'Active', undefined); + } + } + + dispose(): void { + this._disposed = true; + this._tree.removeEngine(this); + this._tree.dropScope(this._scope); + this._units.clear(); + this._pendingIndex.clear(); + const remaining: QueuedRequest[] = []; + for (const request of this._tree.queue) { + if (request.engine === this) { + request.resolve(); + } else { + remaining.push(request); + } + } + this._tree.queue.length = 0; + this._tree.queue.push(...remaining); + this._onDidChangeUnitState.dispose(); + this._onDidCascade.dispose(); + } + + _teardownForCascade( + token: ServiceIdentifier, + tornDown: string[], + parkAsPending: boolean, + ): void | Promise { + if (this._disposed || !this._host.isMaterialized(token)) { + return undefined; + } + this._setUnitState(token, this._unitFor(token), 'Unloading', undefined); + const out = this._host.retire(token); + tornDown.push(this._label({ scope: this._scope, token })); + if (parkAsPending) { + this._markPending(token, undefined, true); + } + return out; + } + + _recheckForCascade(rebuilt: string[], failed: string[]): void { + if (this._disposed || this._activationSuspended > 0) { + return; + } + this._recheckPending(rebuilt, failed); + } + + _applyChangeForCascade(change: CascadeChange): void { + if (this._disposed) { + return; + } + switch (change.action) { + case 'provide': + if (change.descriptor !== undefined) { + this._host.applyProvide(change.token, change.descriptor, change.config); + this._markPending(change.token, change.activation ?? 'eager', false); + } else { + this._host.applyProvideInstance(change.token, change.instance, change.config); + const unit = this._unitFor(change.token); + unit.everActive = true; + this._setUnitState(change.token, unit, 'Active', undefined); + } + break; + case 'unprovide': + this._host.applyUnprovide(change.token); + this._units.delete(change.token); + break; + case 'update': + this._markPending(change.token, undefined, true); + break; + } + } + + private _pump(): void { + if (this._tree.running) { + return; + } + const batch = this._tree.queue.splice(0); + if (batch.length === 0) { + return; + } + this._tree.running = true; + const finish = (error?: unknown): void => { + this._tree.running = false; + for (const request of batch) { + if (error === undefined) { + request.resolve(); + } else { + request.reject(error); + } + } + this._tree.fireSettleWaiters(); + this._pump(); + }; + const orchestrator = batch[0]!.engine; + try { + const out = orchestrator._transact(batch); + if (isPromiseLike(out)) { + Promise.resolve(out).then( + () => { finish(); }, + (error: unknown) => { finish(error); }, + ); + } else { + finish(); + } + } catch (error) { + finish(error); + } + } + + private _transact(batch: QueuedRequest[]): void | Promise { + const changes = mergeBatch(batch); + const started = this._options.now?.() ?? Date.now(); + const reason = changes.map(({ change }) => change.reason).join('; '); + const affected = this._tree.graph.affectedSet( + changes.map(({ engine, change }) => ({ scope: engine._scope, token: change.token })), + ); + for (const ref of affected) { + this._tree.inFlightSet(ref, true); + } + this._tree.orchestrator = this._scope; + const complete = (abort: { waited: boolean; timedOut: boolean }): void | Promise => { + const clear = (): void => { + this._tree.inFlightClear(affected); + this._tree.orchestrator = undefined; + }; + let out: void | Promise; + try { + out = this._applyTransaction(changes, affected, reason, started, abort); + } catch (error) { + clear(); + throw error; + } + if (isPromiseLike(out)) { + return Promise.resolve(out).then(clear, (error: unknown) => { + clear(); + throw error; + }); + } + clear(); + return undefined; + }; + const wait = this._waitForAbort(affected, reason); + if (isPromiseLike(wait)) { + return Promise.resolve(wait).then(complete); + } + return complete(wait); + } + + private _waitForAbort( + affected: readonly ScopedToken[], + reason: string, + ): { waited: boolean; timedOut: boolean } | Promise<{ waited: boolean; timedOut: boolean }> { + const hook = this._options.onWillCascade; + if (hook === undefined) { + return { waited: false, timedOut: false }; + } + let out: void | Promise; + try { + out = hook(affected, reason); + } catch (error) { + onUnexpectedError(error); + return { waited: false, timedOut: false }; + } + if (!isPromiseLike(out)) { + return { waited: false, timedOut: false }; + } + const waitMs = this._options.abortWaitMs ?? DEFAULT_ABORT_WAIT_MS; + return Promise.race([ + Promise.resolve(out).then( + () => ({ waited: true, timedOut: false }), + (error: unknown) => { + onUnexpectedError(error); + return { waited: true, timedOut: false }; + }, + ), + new Promise<{ waited: boolean; timedOut: boolean }>((resolve) => { + setTimeout(() => { resolve({ waited: true, timedOut: true }); }, waitMs); + }), + ]); + } + + private _applyTransaction( + changes: QueuedRequest[], + affected: readonly ScopedToken[], + reason: string, + started: number, + abort: { waited: boolean; timedOut: boolean }, + ): void | Promise { + const tornDown: string[] = []; + const rebuilt: string[] = []; + const failed: string[] = []; + + const teardownOrder = this._tree.graph.reverseTopoOrder(affected); + let index = 0; + const step = (): void | Promise => { + while (index < teardownOrder.length) { + const ref = teardownOrder[index]!; + index += 1; + const owner = engineOf(ref); + if (owner === undefined || owner._disposed) { + continue; + } + const removed = changes.some( + ({ engine, change }) => + engine === owner && change.token === ref.token && change.action === 'unprovide', + ); + const out = owner._teardownForCascade(ref.token, tornDown, !removed); + if (isPromiseLike(out)) { + return Promise.resolve(out).then(step); + } + } + return undefined; + }; + + const after = (): void => { + for (const { engine, change } of changes) { + engine._applyChangeForCascade(change); + } + this._recheckTreeFixpoint(rebuilt, failed); + this._pushHistory({ + seq: ++this._historySeq, + reason, + changes: changes.map(({ engine, change }) => ({ + token: this._label({ scope: engine._scope, token: change.token }), + action: change.action, + })), + affected: affected.map((ref) => this._label(ref)), + tornDown, + rebuilt, + failed, + abortWaited: abort.waited, + abortTimedOut: abort.timedOut, + durationMs: (this._options.now?.() ?? Date.now()) - started, + }); + }; + + const drained = step(); + if (isPromiseLike(drained)) { + return Promise.resolve(drained).then(after); + } + after(); + return undefined; + } + + private _unitFor(token: ServiceIdentifier): UnitRecord { + let unit = this._units.get(token); + if (unit === undefined) { + unit = { state: 'Pending', activation: 'eager', everActive: false }; + this._units.set(token, unit); + this._onDidChangeUnitState.fire({ token: token.toString(), state: 'Pending' }); + } + return unit; + } + + private _setUnitState( + token: ServiceIdentifier, + unit: UnitRecord, + state: UnitState, + error: unknown, + ): void { + const changed = unit.state !== state; + unit.state = state; + unit.error = error; + if (changed) { + this._onDidChangeUnitState.fire({ + token: token.toString(), + state, + error: error === undefined ? undefined : serializeError(error), + }); + } + } + + private _markPending( + token: ServiceIdentifier, + activation?: UnitActivation, + everActive?: boolean, + ): void { + const unit = this._unitFor(token); + this._setUnitState(token, unit, 'Pending', undefined); + if (activation !== undefined) { + unit.activation = activation; + } + if (everActive !== undefined) { + unit.everActive = everActive; + } + } + + private _recheckPending(rebuilt: string[], failed: string[]): void { + for (;;) { + this._pendingIndex.clear(); + const satisfied: ServiceIdentifier[] = []; + for (const [token, unit] of this._units) { + if (unit.state !== 'Pending') continue; + const missing = this._missingDeps(token); + if (missing.length === 0) { + if (unit.everActive || unit.activation === 'eager') { + satisfied.push(token); + } + } else { + for (const dep of missing) { + let waiters = this._pendingIndex.get(dep); + if (waiters === undefined) { + waiters = new Set(); + this._pendingIndex.set(dep, waiters); + } + waiters.add(token); + } + } + } + if (satisfied.length === 0) { + return; + } + const ordered = this._tree.graph.topoOrder( + satisfied.map((token) => ({ scope: this._scope, token })), + ); + for (const ref of ordered) { + this._activate(ref.token, rebuilt, failed); + } + for (const [token, unit] of this._units) { + if ( + (unit.state === 'Pending' || unit.state === 'Activating') && + this._host.isMaterialized(token) + ) { + unit.everActive = true; + this._setUnitState(token, unit, 'Active', undefined); + } + } + } + } + + private _activate(token: ServiceIdentifier, rebuilt: string[], failed: string[]): void { + const unit = this._unitFor(token); + this._setUnitState(token, unit, 'Activating', undefined); + try { + this._host.materialize(token); + unit.everActive = true; + this._setUnitState(token, unit, 'Active', undefined); + rebuilt.push(this._label({ scope: this._scope, token })); + } catch (error) { + this._setUnitState(token, unit, 'Failed', error); + failed.push(this._label({ scope: this._scope, token })); + } + } + + private _missingDeps(token: ServiceIdentifier): Array> { + const recipe = this._host.recipeOf(token); + if (recipe === undefined) { + return [token]; + } + return this._host + .dependenciesOf(recipe) + .filter((dep) => !this._isAvailable(dep)); + } + + private _isAvailable(dep: ServiceIdentifier): boolean { + if (!this._host.isRegistered(dep)) { + return false; + } + const owner = this._host.ownerScopeOf(dep); + const engine = owner === undefined ? undefined : engineOf({ scope: owner, token: dep }); + const state = engine?.stateOf(dep); + if (state === undefined || state === 'Active') { + return true; + } + return ( + state === 'Pending' && + engine !== undefined && + engine.activationOf(dep) === 'ondemand' && + engine.materializable(dep) + ); + } + + private _label(ref: ScopedToken): string { + if (ref.scope === this._tree.orchestrator) { + return ref.token.toString(); + } + return `#${this._tree.seqOf(ref.scope)}:${ref.token.toString()}`; + } + + _mergeKey(): string { + return String(this._tree.seqOf(this._scope)); + } + + private _pushHistory(entry: CascadeHistoryEntry): void { + this._history.push(entry); + const capacity = this._options.historyCapacity ?? DEFAULT_HISTORY_CAPACITY; + if (this._history.length > capacity) { + this._history.splice(0, this._history.length - capacity); + } + this._onDidCascade.fire(entry); + } +} + +function serializeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function engineOf(ref: ScopedToken): CascadeEngine | undefined { + return (ref.scope as Partial).cascade; +} + +function mergeBatch(batch: QueuedRequest[]): QueuedRequest[] { + const byKey = new Map(); + for (const request of batch) { + const key = `${request.engine._mergeKey()}:${request.change.token.toString()}`; + byKey.set(key, request); + } + return [...byKey.values()]; +} diff --git a/packages/agent-core-v2/src/_base/di/collection.ts b/packages/agent-core-v2/src/_base/di/collection.ts new file mode 100644 index 0000000000000000000000000000000000000000..bfc23561b4337e0035724e2a04a20b95adef263e --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/collection.ts @@ -0,0 +1,302 @@ +import { Emitter, type Event } from '../event'; +import type { Ledger } from '../lifecycle/ledger'; +import { storeCustomDependency, type ServiceIdentifier } from './instantiation'; + +export interface CollectionToken { + (target: any, key: string | symbol | undefined, index: number): void; + + readonly name: string; + + readonly __t?: T; + + toString(): string; +} + +export interface DefinitionToken extends CollectionToken { + readonly __definition?: T; +} + +export interface DefinitionRecord { + readonly definition: T; + readonly owner: string; + readonly generation: number; +} + +export interface DefinitionChange { + readonly current: DefinitionRecord | undefined; + readonly previous: DefinitionRecord | undefined; +} + +export interface DefinitionView { + readonly current: DefinitionRecord | undefined; + readonly onDidChangeDefinition: Event>; +} + +const _collectionTokens = new Map>(); +const _collectionTokenSet = new WeakSet(); +const _definitionTokenSet = new WeakSet(); +const _collectionValidators = new WeakMap< + object, + (value: unknown, existing: readonly unknown[]) => void +>(); + +export function collection( + name: string, + options: { readonly validate?: (value: T, existing: readonly T[]) => void } = {}, +): CollectionToken { + const existing = _collectionTokens.get(name); + if (existing !== undefined) { + return existing as CollectionToken; + } + const token = function collectionDecorator( + target: any, + _key: string | symbol | undefined, + index: number, + ): void { + if (arguments.length !== 3) { + throw new Error('@CollectionToken-decorator can only be used to decorate a parameter'); + } + storeCustomDependency(token as unknown as ServiceIdentifier, 'collection', target, index); + } as unknown as CollectionToken; + Object.defineProperty(token, 'toString', { + value: () => `collection:${name}`, + enumerable: false, + }); + Object.defineProperty(token, 'name', { value: name, enumerable: false, configurable: true }); + _collectionTokens.set(name, token as CollectionToken); + _collectionTokenSet.add(token); + if (options.validate !== undefined) { + _collectionValidators.set( + token, + options.validate as (value: unknown, existing: readonly unknown[]) => void, + ); + } + return token; +} + +export function definition(name: string): DefinitionToken { + const token = collection(name, { + validate: (_value, existing) => { + if (existing.length > 0) throw new Error(`Definition ${name} already has an active provider`); + }, + }) as DefinitionToken; + _definitionTokenSet.add(token); + return token; +} + +export function isCollectionToken(thing: unknown): thing is CollectionToken { + return typeof thing === 'function' && _collectionTokenSet.has(thing); +} + +export function isDefinitionToken(thing: unknown): thing is DefinitionToken { + return typeof thing === 'function' && _definitionTokenSet.has(thing); +} + +export interface CollectionRecord { + readonly value: T; + readonly providerName: string; + readonly scopePath: string; +} + +export interface CollectionChange { + readonly added: readonly T[]; + readonly removed: readonly T[]; +} + +export interface CollectionView { + readonly items: readonly T[]; + readonly records: readonly CollectionRecord[]; + readonly onDidChange: Event>; +} + +interface StoredRecord { + readonly id: number; + readonly value: unknown; + readonly providerName: string; + readonly scopePath: string; + readonly provider: object; + readonly providerBook: Ledger; +} + +export type { StoredRecord }; + +export class CollectionStore { + private readonly _records = new Map< + CollectionToken, + Map + >(); + private readonly _views = new Set>(); + private _nextId = 0; + + constructor(private readonly _parentOf: (container: object) => object | undefined) {} + + addRecord( + token: CollectionToken, + provider: object, + providerName: string, + scopePath: string, + providerBook: Ledger, + value: T, + ): () => void { + let records = this._records.get(token as CollectionToken); + if (records === undefined) { + records = new Map(); + this._records.set(token as CollectionToken, records); + } + _collectionValidators.get(token)?.( + value, + [...records.values()].map((entry) => entry.value), + ); + const record: StoredRecord = { + id: ++this._nextId, + value, + providerName, + scopePath, + provider, + providerBook, + }; + records.set(record.id, record); + const fire = (view: CollectionViewImpl, kind: 'added' | 'removed'): void => { + if (view.consumer === provider || this._isRelated(view.consumer, provider)) { + view._fireDelta(kind, [record]); + } + }; + for (const view of this._views) { + if (view.token === (token as unknown as CollectionToken)) { + fire(view, 'added'); + } + } + return () => { + if (!records.delete(record.id)) { + return; + } + for (const view of this._views) { + if (view.token === (token as unknown as CollectionToken)) { + fire(view, 'removed'); + } + } + }; + } + + createView(token: CollectionToken, consumer: object): CollectionViewImpl { + const view = new CollectionViewImpl(this, token, consumer); + this._views.add(view as unknown as CollectionViewImpl); + return view; + } + + dropView(view: CollectionViewImpl): void { + this._views.delete(view); + } + + recordsFor(token: CollectionToken, consumer: object): CollectionRecord[] { + const records = this._records.get(token as CollectionToken); + if (records === undefined) { + return []; + } + const out: CollectionRecord[] = []; + for (const record of records.values()) { + if (record.provider === consumer || this._isRelated(consumer, record.provider)) { + out.push({ + value: record.value as T, + providerName: record.providerName, + scopePath: record.scopePath, + }); + } + } + return out; + } + + storedRecordsFor( + token: CollectionToken, + consumer: object, + ): readonly StoredRecord[] { + const records = this._records.get(token); + if (records === undefined) { + return []; + } + const out: StoredRecord[] = []; + for (const record of records.values()) { + if (record.provider === consumer || this._isRelated(consumer, record.provider)) { + out.push(record); + } + } + return out; + } + + definitionFor(token: CollectionToken, consumer: object): DefinitionRecord | undefined { + const record = this.storedRecordsFor( + token as CollectionToken, + consumer, + )[0]; + if (record === undefined) return undefined; + return { + definition: record.value as T, + owner: `${record.providerName}@${record.scopePath}`, + generation: record.id, + }; + } + + private _isRelated(consumer: object, provider: object): boolean { + for (let c: object | undefined = consumer; c !== undefined; c = this._parentOf(c)) { + if (c === provider) return true; + } + for (let p: object | undefined = provider; p !== undefined; p = this._parentOf(p)) { + if (p === consumer) return true; + } + return false; + } +} + +export class CollectionViewImpl implements CollectionView, DefinitionView { + private readonly _onDidChange = new Emitter>(); + private readonly _onDidChangeDefinition = new Emitter>(); + readonly onDidChange: Event> = this._onDidChange.event; + readonly onDidChangeDefinition: Event> = + this._onDidChangeDefinition.event; + + constructor( + private readonly _store: CollectionStore, + readonly token: CollectionToken, + readonly consumer: object, + ) {} + + get records(): CollectionRecord[] { + return this._store.recordsFor(this.token, this.consumer); + } + + get items(): readonly T[] { + return this.records.map((record) => record.value); + } + + get current(): DefinitionRecord | undefined { + return this._store.definitionFor(this.token, this.consumer); + } + + _fireDelta(kind: 'added' | 'removed', records: readonly StoredRecord[]): void { + const previous = kind === 'removed' ? this.definitionRecord(records[0]) : undefined; + const values = records.map((record) => record.value as T); + this._onDidChange.fire( + kind === 'added' + ? { added: values, removed: [] } + : { added: [], removed: values }, + ); + if (isDefinitionToken(this.token)) { + this._onDidChangeDefinition.fire({ current: this.current, previous }); + } + } + + dispose(): void { + this._store.dropView(this as unknown as CollectionViewImpl); + this._onDidChange.dispose(); + this._onDidChangeDefinition.dispose(); + } + + private definitionRecord(record: StoredRecord | undefined): DefinitionRecord | undefined { + if (record === undefined) return undefined; + return { + definition: record.value as T, + owner: `${record.providerName}@${record.scopePath}`, + generation: record.id, + }; + } +} diff --git a/packages/agent-core-v2/src/_base/di/dependencyGraph.ts b/packages/agent-core-v2/src/_base/di/dependencyGraph.ts new file mode 100644 index 0000000000000000000000000000000000000000..10e931237c5e67746f459ea1e4b28f792e84717d --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/dependencyGraph.ts @@ -0,0 +1,262 @@ +import type { ServiceIdentifier } from './instantiation'; + +export interface ScopedToken { + readonly scope: object; + readonly token: ServiceIdentifier; +} + +export type DependencyEdgeKind = 'instance' | 'collection'; + +export interface DependencyEdge { + readonly consumer: ScopedToken; + readonly dependency: ScopedToken; + readonly kind: DependencyEdgeKind; +} + +export class PairIndex { + private readonly _map = new Map, + V + >>(); + + get(scope: object, token: ServiceIdentifier): V | undefined { + return this._map.get(scope)?.get(token); + } + + set(scope: object, token: ServiceIdentifier, value: V): void { + let inner = this._map.get(scope); + if (inner === undefined) { + inner = new Map(); + this._map.set(scope, inner); + } + inner.set(token, value); + } + + delete(scope: object, token: ServiceIdentifier): void { + const inner = this._map.get(scope); + if (inner === undefined) return; + inner.delete(token); + if (inner.size === 0) { + this._map.delete(scope); + } + } + + deleteScope(scope: object): void { + this._map.delete(scope); + } + + get size(): number { + return this._map.size; + } + + entries(): Array<[ScopedToken, V]> { + const out: Array<[ScopedToken, V]> = []; + for (const [scope, inner] of this._map) { + for (const [token, value] of inner) { + out.push([{ scope, token }, value]); + } + } + return out; + } + + clear(): void { + this._map.clear(); + } +} + +export class DependencyGraph { + private readonly _refByInstance = new Map(); + private readonly _instanceByRef = new PairIndex(); + private readonly _out = new Map>(); + private readonly _in = new PairIndex>(); + + addInstance( + instance: object, + scope: object, + token: ServiceIdentifier, + ): void { + const ref: ScopedToken = { scope, token }; + this._refByInstance.set(instance, ref); + this._instanceByRef.set(scope, token, instance); + } + + removeInstance(instance: object): void { + const ref = this._refByInstance.get(instance); + if (ref !== undefined) { + this._refByInstance.delete(instance); + if (this._instanceByRef.get(ref.scope, ref.token) === instance) { + this._instanceByRef.delete(ref.scope, ref.token); + } + } + const out = this._out.get(instance); + if (out !== undefined) { + for (const [dependency] of out.entries()) { + const inbound = this._in.get(dependency.scope, dependency.token); + if (inbound !== undefined) { + inbound.delete(instance); + if (inbound.size === 0) { + this._in.delete(dependency.scope, dependency.token); + } + } + } + this._out.delete(instance); + } + } + + removeScope(scope: object): void { + for (const [instance, ref] of this._refByInstance) { + if (ref.scope === scope) { + this.removeInstance(instance); + } + } + this._instanceByRef.deleteScope(scope); + this._in.deleteScope(scope); + for (const [instance, out] of this._out) { + out.deleteScope(scope); + if (out.size === 0) { + this._out.delete(instance); + } + } + } + + addEdge( + consumerInstance: object, + dependency: ScopedToken, + kind: DependencyEdgeKind = 'instance', + ): void { + let out = this._out.get(consumerInstance); + if (out === undefined) { + out = new PairIndex(); + this._out.set(consumerInstance, out); + } + out.set(dependency.scope, dependency.token, kind); + + let inbound = this._in.get(dependency.scope, dependency.token); + if (inbound === undefined) { + inbound = new Map(); + this._in.set(dependency.scope, dependency.token, inbound); + } + inbound.set(consumerInstance, kind); + } + + affectedSet(changed: Iterable): ScopedToken[] { + const seen = new PairIndex(); + const queue: ScopedToken[] = []; + const push = (ref: ScopedToken): void => { + if (seen.get(ref.scope, ref.token) !== undefined) return; + seen.set(ref.scope, ref.token, true); + queue.push(ref); + }; + for (const ref of changed) { + push(ref); + } + const affected: ScopedToken[] = []; + while (queue.length > 0) { + const ref = queue.pop()!; + affected.push(ref); + const inbound = this._in.get(ref.scope, ref.token); + if (inbound === undefined) continue; + for (const [consumerInstance, kind] of inbound) { + if (kind !== 'instance') continue; + const consumer = this._refByInstance.get(consumerInstance); + if (consumer === undefined) continue; + push(consumer); + } + } + return affected; + } + + topoOrder(tokens: Iterable): ScopedToken[] { + const subset = new PairIndex(); + for (const ref of tokens) { + subset.set(ref.scope, ref.token, ref); + } + const ordered: ScopedToken[] = []; + const done = new PairIndex(); + const visit = (ref: ScopedToken): void => { + if (done.get(ref.scope, ref.token) !== undefined) return; + done.set(ref.scope, ref.token, true); + for (const dependency of this._dependenciesOf(ref, subset)) { + visit(dependency); + } + ordered.push(ref); + }; + for (const [, ref] of subset.entries()) { + visit(ref); + } + return ordered; + } + + reverseTopoOrder(tokens: Iterable): ScopedToken[] { + return this.topoOrder(tokens).toReversed(); + } + + findCycle(label: (ref: ScopedToken) => string): string[] | null { + const state = new PairIndex<'visiting' | 'done'>(); + const path: ScopedToken[] = []; + const visit = (ref: ScopedToken): string[] | null => { + state.set(ref.scope, ref.token, 'visiting'); + path.push(ref); + for (const dependency of this._dependenciesOf(ref, undefined)) { + const mark = state.get(dependency.scope, dependency.token); + if (mark === 'done') continue; + if (mark === 'visiting') { + const start = path.findIndex( + (entry) => + entry.scope === dependency.scope && entry.token === dependency.token, + ); + return [...path.slice(start), dependency].map(label); + } + const cycle = visit(dependency); + if (cycle !== null) return cycle; + } + path.pop(); + state.set(ref.scope, ref.token, 'done'); + return null; + }; + for (const [ref] of this._instanceByRef.entries()) { + if (state.get(ref.scope, ref.token) !== undefined) continue; + const cycle = visit(ref); + if (cycle !== null) return cycle; + } + return null; + } + + edges(): DependencyEdge[] { + const edges: DependencyEdge[] = []; + for (const [consumerInstance, out] of this._out) { + const consumer = this._refByInstance.get(consumerInstance); + if (consumer === undefined) continue; + for (const [dependency, kind] of out.entries()) { + edges.push({ consumer, dependency, kind }); + } + } + return edges; + } + + clear(): void { + this._refByInstance.clear(); + this._instanceByRef.clear(); + this._out.clear(); + this._in.clear(); + } + + private _dependenciesOf( + ref: ScopedToken, + subset: PairIndex | undefined, + ): ScopedToken[] { + const instance = this._instanceByRef.get(ref.scope, ref.token); + if (instance === undefined) return []; + const out = this._out.get(instance); + if (out === undefined) return []; + const result: ScopedToken[] = []; + for (const [dependency, kind] of out.entries()) { + if (kind !== 'instance') continue; + if (subset !== undefined && subset.get(dependency.scope, dependency.token) === undefined) { + continue; + } + result.push(dependency); + } + return result; + } +} diff --git a/packages/agent-core-v2/src/_base/di/descriptors.ts b/packages/agent-core-v2/src/_base/di/descriptors.ts new file mode 100644 index 0000000000000000000000000000000000000000..e9f81dd5830017f856d324ccafc674b187dabb36 --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/descriptors.ts @@ -0,0 +1,14 @@ +export class SyncDescriptor { + public readonly ctor: any; + + constructor( + ctor: new (...args: any[]) => T, + public readonly staticArguments: ReadonlyArray = [], + ) { + this.ctor = ctor; + } +} + +export interface SyncDescriptor0 { + readonly ctor: new () => T; +} diff --git a/packages/agent-core-v2/src/_base/di/errors.ts b/packages/agent-core-v2/src/_base/di/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..391d8a908b23005445df060786f7f31dc4048971 --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/errors.ts @@ -0,0 +1,30 @@ +import type { Graph } from './graph'; + +export class CyclicDependencyError extends Error { + readonly path: ReadonlyArray; + + constructor(pathOrGraph: ReadonlyArray | Graph) { + if (Array.isArray(pathOrGraph)) { + const path = pathOrGraph as ReadonlyArray; + super(`Cyclic DI dependency detected: ${path.join(' → ')}`); + this.path = path; + } else { + const graph = pathOrGraph as Graph; + const cycle = graph.findCycleSlow(); + const detail = cycle ?? `UNABLE to detect cycle, dumping graph:\n${graph.toString()}`; + super(`cyclic dependency between services: ${detail}`); + this.path = cycle ? cycle.split(' -> ') : []; + } + this.name = 'CyclicDependencyError'; + } +} + +export class CascadeConflictError extends Error { + constructor( + readonly token: string, + readonly detail: string, + ) { + super(`Cascade conflict resolving '${token}': ${detail}`); + this.name = 'CascadeConflictError'; + } +} diff --git a/packages/agent-core-v2/src/_base/di/fiber.ts b/packages/agent-core-v2/src/_base/di/fiber.ts new file mode 100644 index 0000000000000000000000000000000000000000..aed277c56621e62c4118ab3ff978f58cb089fcb6 --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/fiber.ts @@ -0,0 +1,714 @@ +import type { IDisposable } from './lifecycle'; +import type { Emitter } from '../event'; +import { isPromiseLike, type EffectBody } from '../lifecycle/disposer'; +import { Ledger, type LedgerEntry } from '../lifecycle/ledger'; +import { + collection, + isCollectionToken, + type CollectionToken, + type CollectionView, +} from './collection'; +import { SyncDescriptor } from './descriptors'; +import { + isServiceIdentifier, + ScopeActivation, + _util, + type LiveRef, + type ServiceIdentifier, +} from './instantiation'; + +export enum FiberState { + Pending = 0, + Activating = 1, + Active = 2, + Unloading = 3, + Failed = 4, +} + +export interface ConfigSchema { + readonly '~standard': { + readonly validate: (value: unknown) => unknown; + }; +} + +export interface RecipeStatics { + readonly name?: string; + readonly inject?: readonly ServiceIdentifier[]; + readonly Config?: ConfigSchema; + readonly meta?: Record; +} + +export type ServiceClassRecipe = + (new (...args: any[]) => unknown) & RecipeStatics; + +export type ServiceFunctionRecipe = (( + fiber: Fiber, + config?: any, +) => any) & + RecipeStatics; + +export type ServiceObjectRecipe = { + apply( + fiber: Fiber, + config?: any, + ): any; +} & RecipeStatics; + +export type ServiceRecipe = + | ServiceClassRecipe + | ServiceFunctionRecipe + | ServiceObjectRecipe; + +export interface FiberProvideOptions { + readonly config?: unknown; + readonly activation?: ScopeActivation; +} + +export interface Fiber { + readonly name: string; + readonly state: FiberState; + readonly config: unknown; + + provide( + id: ServiceIdentifier, + recipe: ServiceClassRecipe, + opts?: FiberProvideOptions, + ): FiberHandle; + provide(id: ServiceIdentifier, instance: T): FiberHandle; + provide(recipe: ServiceRecipe, opts?: FiberProvideOptions): FiberHandle; + provide(token: CollectionToken, value: T): FiberHandle; + + effect(body: EffectBody, label?: string): FiberHandle; + + on(event: string | Emitter, handler: (e: any) => void): FiberHandle; + + get(id: ServiceIdentifier): T; + ref(id: ServiceIdentifier): LiveRef; +} + +export interface FiberHandle extends PromiseLike> { + readonly name: string; + readonly state: FiberState; + readonly uid: number; + update(config?: unknown): Promise; + dispose(): Promise; +} + +export class FiberProtocolError extends Error { + constructor(detail: string) { + super(detail); + this.name = 'FiberProtocolError'; + } +} + +export class ServiceRecipeError extends Error { + constructor(detail: string) { + super(detail); + this.name = 'ServiceRecipeError'; + } +} + +export interface ConstructionFrame { + readonly ctor: new (...args: any[]) => any; + readonly config: unknown; + readonly token: ServiceIdentifier | undefined; + readonly host: FiberHost; +} + +const _constructionStack: ConstructionFrame[] = []; + +export function pushConstructionFrame(frame: ConstructionFrame): void { + _constructionStack.push(frame); +} + +export function popConstructionFrame(): void { + _constructionStack.pop(); +} + +export function currentConstruction(): ConstructionFrame | undefined { + return _constructionStack.at(-1); +} + +export const SERVICE_MARK = Symbol('serviceUnit'); + +export function isServiceRecipe(ctor: any): ctor is ServiceClassRecipe { + return typeof ctor === 'function' && ctor.prototype?.[SERVICE_MARK] === true; +} + +export function isClassRecipe(recipe: unknown): recipe is ServiceClassRecipe { + return ( + typeof recipe === 'function' && + Object.prototype.hasOwnProperty.call(recipe, 'prototype') + ); +} + +export type BufferedOp = (runtime: Fiber) => void; + +export interface UnitInternals { + readonly unitBook: Ledger; + takeUnitBuffer(): BufferedOp[] | null; + setUnitRuntime(runtime: Fiber): void; +} + +export interface FiberHost { + mintUid(): number; + provideToken( + id: ServiceIdentifier, + descriptor: SyncDescriptor, + options: { + readonly activation: 'eager' | 'ondemand'; + readonly config?: unknown; + }, + ): TokenProvideCore; + provideTokenInstance(id: ServiceIdentifier, instance: T): TokenProvideCore; + tokenState(id: ServiceIdentifier): string | undefined; + updateToken(id: ServiceIdentifier, config: unknown, hasConfig: boolean): Promise; + resolveTokenWhenAvailable(id: ServiceIdentifier): Promise; + resolveInstance(id: ServiceIdentifier): T; + materializedInstance(id: ServiceIdentifier): T | undefined; + liveRef(id: ServiceIdentifier): LiveRef; + recordInstanceEdge(node: object | undefined, id: ServiceIdentifier): void; + collectionView(token: CollectionToken): CollectionView; + addCollectionRecord( + token: CollectionToken, + providerName: string, + providerBook: Ledger, + value: T, + ): () => void; + constructService(ctor: new (...args: any[]) => T, config: unknown): T; +} + +export interface TokenProvideCore { + uid(): number; + dispose(): Promise; + release(): void; +} + +export type FiberEventResolver = ( + host: FiberHost, + event: string, + handler: (e: any) => void, +) => IDisposable; + +let _eventResolver: FiberEventResolver | undefined; + +export function setFiberEventResolver(resolver: FiberEventResolver | undefined): void { + _eventResolver = resolver; +} + +export function bindServiceUnit(instance: UnitInternals & IDisposable, frame: ConstructionFrame): void { + const buffer = instance.takeUnitBuffer(); + if (buffer === null) { + return; + } + const ctor = (instance as any).constructor as ServiceClassRecipe; + const runtime = new FiberRuntime( + frame.host, + instance.unitBook, + recipeName(ctor), + frame.config, + frame.token, + new Set(_util.getInstanceDependencies(ctor as unknown as _util.DI_TARGET_OBJ).map((d) => d.id)), + instance, + ); + instance.setUnitRuntime(runtime); + try { + for (const op of buffer) { + op(runtime); + } + } catch (error) { + instance.dispose(); + throw error; + } +} + +function recipeName(recipe: RecipeStatics & { name?: string }): string { + return recipe.name ?? 'anonymous'; +} + +function validateConfig(schema: ConfigSchema | undefined, config: unknown, name: string): unknown { + if (schema === undefined) { + return config; + } + const out = schema['~standard'].validate(config); + if (isPromiseLike(out)) { + throw new ServiceRecipeError(`config schema of unit '${name}' must validate synchronously`); + } + const result = out as { + readonly value?: unknown; + readonly issues?: ReadonlyArray<{ readonly message: string }>; + }; + if (result.issues !== undefined && result.issues.length > 0) { + throw new ServiceRecipeError( + `invalid config for unit '${name}': ${result.issues.map((issue) => issue.message).join('; ')}`, + ); + } + return 'value' in result ? result.value : config; +} + +function mapUnitState(state: string | undefined): FiberState { + if (state === undefined) { + return FiberState.Active; + } + switch (state) { + case 'Pending': + return FiberState.Pending; + case 'Activating': + return FiberState.Activating; + case 'Unloading': + return FiberState.Unloading; + case 'Failed': + return FiberState.Failed; + default: + return FiberState.Active; + } +} + +export class FiberRuntime implements Fiber { + constructor( + private readonly _host: FiberHost, + private readonly _book: Ledger, + readonly name: string, + readonly config: unknown, + private readonly _token: ServiceIdentifier | undefined, + private readonly _declared: ReadonlySet>, + private readonly _edgeNode: object | undefined, + ) {} + + get state(): FiberState { + if (this._token !== undefined) { + return mapUnitState(this._host.tokenState(this._token)); + } + return FiberState.Active; + } + + provide( + id: ServiceIdentifier, + recipe: ServiceClassRecipe, + opts?: FiberProvideOptions, + ): FiberHandle; + provide(id: ServiceIdentifier, instance: T): FiberHandle; + provide(recipe: ServiceRecipe, opts?: FiberProvideOptions): FiberHandle; + provide(token: CollectionToken, value: T): FiberHandle; + provide( + first: ServiceIdentifier | ServiceRecipe | CollectionToken, + second?: any, + third?: FiberProvideOptions, + ): FiberHandle { + if (isCollectionToken(first)) { + return this._provideRecord(first, second); + } + if (isServiceIdentifier(first)) { + if (isClassRecipe(second)) { + return this._provideToken(first, second, third); + } + if ( + typeof second === 'function' || + (second !== null && typeof second === 'object' && typeof second.apply === 'function') + ) { + throw new ServiceRecipeError( + `token-bound provide of '${String(first)}' requires a class recipe or an instance (function/object recipes are only valid for the anonymous form)`, + ); + } + return this._provideTokenInstance(first, second); + } + return this._provideAnonymous(first as ServiceRecipe, second as FiberProvideOptions | undefined); + } + + effect(body: EffectBody, label?: string): FiberHandle { + const entry = this._book.effect(body, label ?? `effect:${this.name}`); + return new BasicFiberHandle({ + name: label ?? `effect:${this.name}`, + uid: this._host.mintUid(), + state: () => (entry.disposed ? FiberState.Unloading : FiberState.Active), + update: async () => { + await entry.dispose(); + if (this._book.isActive) { + this._book.effect(body, label ?? `effect:${this.name}`); + } + }, + dispose: async () => { + await entry.dispose(); + }, + whenActive: () => Promise.resolve(), + }); + } + + on(event: string | Emitter, handler: (e: any) => void): FiberHandle { + let subscription: IDisposable; + if (typeof event === 'string') { + if (_eventResolver === undefined) { + throw new FiberProtocolError( + `no event resolver registered — cannot subscribe to '${event}'`, + ); + } + subscription = _eventResolver(this._host, event, handler); + } else if (typeof event?.event === 'function') { + subscription = event.event(handler); + } else { + throw new FiberProtocolError(`unsupported event source for unit '${this.name}'`); + } + const label = typeof event === 'string' ? `on:${event}` : `on:${event.constructor?.name ?? 'emitter'}`; + const entry = this._book.register(() => { + subscription.dispose(); + }, label); + return new BasicFiberHandle({ + name: label, + uid: this._host.mintUid(), + state: () => (entry.disposed ? FiberState.Unloading : FiberState.Active), + update: async () => {}, + dispose: async () => { + await entry.dispose(); + }, + whenActive: () => Promise.resolve(), + }); + } + + get(id: ServiceIdentifier): T { + if (!this._declared.has(id)) { + throw new FiberProtocolError( + `unit '${this.name}' resolves undeclared dependency '${String(id)}' — declare it as a constructor parameter (class recipe) or in the inject static (function recipe)`, + ); + } + this._host.recordInstanceEdge(this._edgeNode, id); + return this._host.resolveInstance(id); + } + + ref(id: ServiceIdentifier): LiveRef { + return this._host.liveRef(id); + } + + private _provideToken( + id: ServiceIdentifier, + recipe: ServiceClassRecipe, + opts: FiberProvideOptions | undefined, + ): FiberHandle { + if (!isClassRecipe(recipe)) { + throw new ServiceRecipeError( + `token-bound provide of '${String(id)}' requires a class recipe (function/object recipes are only valid for the anonymous form)`, + ); + } + const name = recipe.name ?? String(id); + const config = validateConfig(recipe.Config, opts?.config, name); + const core = this._host.provideToken( + id, + new SyncDescriptor(recipe as new (...args: any[]) => T), + { + activation: opts?.activation === ScopeActivation.OnDemand ? 'ondemand' : 'eager', + config, + }, + ); + const entry = this._book.register(() => core.dispose(), `provide:${String(id)}`); + return new BasicFiberHandle({ + name, + uid: undefined, + uidOf: () => core.uid(), + state: () => mapUnitState(this._host.tokenState(id)), + update: (next) => { + const hasConfig = next !== undefined; + return this._host.updateToken( + id, + hasConfig ? validateConfig(recipe.Config, next, name) : undefined, + hasConfig, + ); + }, + dispose: async () => { + await entry.dispose(); + }, + whenActive: () => this._host.resolveTokenWhenAvailable(id).then(() => undefined), + }); + } + + private _provideTokenInstance(id: ServiceIdentifier, instance: T): FiberHandle { + const core = this._host.provideTokenInstance(id, instance); + const entry = this._book.register(() => core.dispose(), `provide:${String(id)}`); + return new BasicFiberHandle({ + name: String(id), + uid: undefined, + uidOf: () => core.uid(), + state: () => mapUnitState(this._host.tokenState(id)), + update: () => this._host.updateToken(id, undefined, false), + dispose: async () => { + await entry.dispose(); + }, + whenActive: () => this._host.resolveTokenWhenAvailable(id).then(() => undefined), + }); + } + + private _provideAnonymous(recipe: ServiceRecipe, opts: FiberProvideOptions | undefined): FiberHandle { + if (isClassRecipe(recipe)) { + return this._provideAnonymousClass(recipe, opts); + } + return this._provideFunction(recipe, opts); + } + + private _provideAnonymousClass(recipe: ServiceClassRecipe, opts: FiberProvideOptions | undefined): FiberHandle { + const name = recipeName(recipe); + let config = validateConfig(recipe.Config, opts?.config, name); + let state = FiberState.Activating; + let failure: unknown; + let instance: unknown; + let entry: LedgerEntry | undefined; + const construct = (nextConfig: unknown): void => { + state = FiberState.Activating; + instance = this._host.constructService(recipe, nextConfig); + entry = this._book.register(() => { + state = FiberState.Unloading; + (instance as Partial).dispose?.(); + }, `provide:${name}`); + state = FiberState.Active; + }; + try { + construct(config); + } catch (error) { + state = FiberState.Failed; + failure = error; + throw error; + } + for (const dependency of _util.getInstanceDependencies(recipe as unknown as _util.DI_TARGET_OBJ)) { + this._host.recordInstanceEdge(this._edgeNode, dependency.id); + } + return new BasicFiberHandle({ + name, + uid: this._host.mintUid(), + state: () => state, + update: async (next) => { + await entry?.dispose(); + entry = undefined; + try { + if (next !== undefined) { + config = validateConfig(recipe.Config, next, name); + } + construct(config); + } catch (error) { + state = FiberState.Failed; + failure = error; + throw error; + } + }, + dispose: async () => { + await entry?.dispose(); + entry = undefined; + }, + whenActive: () => (failure !== undefined ? Promise.reject(failure) : Promise.resolve()), + }); + } + + private _provideFunction(recipe: ServiceFunctionRecipe | ServiceObjectRecipe, opts: FiberProvideOptions | undefined): FiberHandle { + const name = recipeName(recipe); + const config = validateConfig(recipe.Config, opts?.config, name); + const book = new Ledger(`unit:${name}`); + const anchor = this._book.register((reason) => book.teardown(reason), `provide:${name}`); + const facade = new FiberRuntime( + this._host, + book, + name, + config, + undefined, + new Set(recipe.inject ?? []), + this._edgeNode, + ); + const run = (): void => { + let out: unknown; + if (typeof recipe === 'function') { + out = recipe(facade, config); + } else { + out = recipe.apply(facade, config); + } + book.effect((() => out) as EffectBody, `effect:${name}`); + }; + try { + run(); + } catch (error) { + void book.teardown('unload'); + anchor.release(); + throw error; + } + return new BasicFiberHandle({ + name, + uid: this._host.mintUid(), + state: () => (anchor.disposed ? FiberState.Unloading : FiberState.Active), + update: async () => { + await book.clear('unload'); + try { + run(); + } catch (error) { + void book.teardown('unload'); + anchor.release(); + throw error; + } + }, + dispose: async () => { + await anchor.dispose(); + }, + whenActive: () => Promise.resolve(), + }); + } + + private _provideRecord(token: CollectionToken, value: T): FiberHandle { + const remove = this._host.addCollectionRecord(token, this.name, this._book, value); + const entry = this._book.register(() => { + remove(); + }, `provide:${token.name}`); + return new BasicFiberHandle({ + name: `${this.name}→${token.name}`, + uid: this._host.mintUid(), + state: () => (entry.disposed ? FiberState.Unloading : FiberState.Active), + update: async () => {}, + dispose: async () => { + await entry.dispose(); + }, + whenActive: () => Promise.resolve(), + }); + } +} + +interface BasicHandleParts { + readonly name: string; + readonly uid?: number; + readonly uidOf?: () => number; + readonly state: () => FiberState; + readonly update: (config: unknown) => Promise; + readonly dispose: () => Promise; + readonly whenActive: () => Promise; +} + +class BasicFiberHandle implements FiberHandle { + constructor(private readonly _parts: BasicHandleParts) {} + + get name(): string { + return this._parts.name; + } + + get state(): FiberState { + return this._parts.state(); + } + + get uid(): number { + if (this._parts.uidOf !== undefined) { + return this._parts.uidOf(); + } + return this._parts.uid!; + } + + update(config?: unknown): Promise { + return this._parts.update(config); + } + + dispose(): Promise { + return this._parts.dispose(); + } + + // eslint-disable-next-line eslint-plugin-unicorn(no-thenable) + then, TResult2 = never>( + onfulfilled?: ((value: FiberHandle) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): PromiseLike { + return thenSettle(this._parts.whenActive().then(() => settledView(this)), onfulfilled, onrejected); + } +} + +interface SettledHandleView { + readonly name: string; + readonly state: FiberState; + readonly uid: number; + update(config?: unknown): Promise; + dispose(): Promise; +} + +function settledView(handle: FiberHandle): SettledHandleView { + return { + get name() { + return handle.name; + }, + get state() { + return handle.state; + }, + get uid() { + return handle.uid; + }, + update: (config?: unknown) => handle.update(config), + dispose: () => handle.dispose(), + }; +} + +function thenSettle( + settled: Promise, + onfulfilled: ((value: FiberHandle) => TResult1 | PromiseLike) | null | undefined, + onrejected: ((reason: unknown) => TResult2 | PromiseLike) | null | undefined, +): PromiseLike { + return settled.then( + onfulfilled as unknown as (value: SettledHandleView) => TResult1 | PromiseLike, + onrejected, + ); +} + +export class PendingFiberHandle implements FiberHandle { + private _real: FiberHandle | undefined; + private _disposed = false; + + constructor(private readonly _pendingName: string) {} + + attach(real: FiberHandle): void { + if (this._disposed) { + void real.dispose(); + return; + } + this._real = real; + } + + get name(): string { + return this._real?.name ?? this._pendingName; + } + + get state(): FiberState { + return this._real?.state ?? FiberState.Activating; + } + + get uid(): number { + if (this._real === undefined) { + throw new FiberProtocolError( + `handle '${this._pendingName}' has not been flushed yet (construction protocol)`, + ); + } + return this._real.uid; + } + + update(config?: unknown): Promise { + if (this._real === undefined) { + return Promise.reject( + new FiberProtocolError(`handle '${this._pendingName}' has not been flushed yet`), + ); + } + return this._real.update(config); + } + + async dispose(): Promise { + this._disposed = true; + await this._real?.dispose(); + } + + // eslint-disable-next-line eslint-plugin-unicorn(no-thenable) — FiberHandle is a thenable by design + then, TResult2 = never>( + onfulfilled?: ((value: FiberHandle) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): PromiseLike { + if (this._real !== undefined) { + return this._real.then(onfulfilled, onrejected); + } + return thenSettle(Promise.resolve(settledView(this)), onfulfilled, onrejected); + } +} + +const _scopeUnitsTokens = new Map>(); + +export function ScopeUnits(kind: string): CollectionToken { + let token = _scopeUnitsTokens.get(kind); + if (token === undefined) { + token = collection(`scope-units:${kind}`); + _scopeUnitsTokens.set(kind, token); + } + return token; +} + +export type { EffectBody }; diff --git a/packages/agent-core-v2/src/_base/di/graph.ts b/packages/agent-core-v2/src/_base/di/graph.ts new file mode 100644 index 0000000000000000000000000000000000000000..e137209d750b640e1392579b57a982288aff4bf9 --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/graph.ts @@ -0,0 +1,89 @@ +export class Node { + readonly incoming = new Map>(); + readonly outgoing = new Map>(); + + constructor( + readonly key: string, + readonly data: T + ) { } +} + +export class Graph { + private readonly _nodes = new Map>(); + + constructor(private readonly _hashFn: (element: T) => string) { } + + roots(): Node[] { + const ret: Node[] = []; + for (const node of this._nodes.values()) { + if (node.outgoing.size === 0) { + ret.push(node); + } + } + return ret; + } + + insertEdge(from: T, to: T): void { + const fromNode = this.lookupOrInsertNode(from); + const toNode = this.lookupOrInsertNode(to); + fromNode.outgoing.set(toNode.key, toNode); + toNode.incoming.set(fromNode.key, fromNode); + } + + removeNode(data: T): void { + const key = this._hashFn(data); + this._nodes.delete(key); + for (const node of this._nodes.values()) { + node.outgoing.delete(key); + node.incoming.delete(key); + } + } + + lookupOrInsertNode(data: T): Node { + const key = this._hashFn(data); + let node = this._nodes.get(key); + if (!node) { + node = new Node(key, data); + this._nodes.set(key, node); + } + return node; + } + + isEmpty(): boolean { + return this._nodes.size === 0; + } + + toString(): string { + const data: string[] = []; + for (const [key, value] of this._nodes) { + data.push(`${key}\n\t(-> incoming)[${[...value.incoming.keys()].join(', ')}]\n\t(outgoing ->)[${[...value.outgoing.keys()].join(',')}]\n`); + } + return data.join('\n'); + } + + findCycleSlow() { + for (const [id, node] of this._nodes) { + const seen = new Set([id]); + const res = this._findCycle(node, seen); + if (res) { + return res; + } + } + return undefined; + } + + private _findCycle(node: Node, seen: Set): string | undefined { + for (const [id, outgoing] of node.outgoing) { + if (seen.has(id)) { + return [...seen, id].join(' -> '); + } + seen.add(id); + const value = this._findCycle(outgoing, seen); + if (value) { + return value; + } + seen.delete(id); + } + return undefined; + } +} diff --git a/packages/agent-core-v2/src/_base/di/instantiation.ts b/packages/agent-core-v2/src/_base/di/instantiation.ts new file mode 100644 index 0000000000000000000000000000000000000000..7adc73615ea6eec56b678fb8ffa69823fd21e53a --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/instantiation.ts @@ -0,0 +1,212 @@ +import type { SyncDescriptor, SyncDescriptor0 } from './descriptors'; +import type { CascadeEngine } from './cascadeEngine'; +import type { Event } from '../event'; +import type { DisposableStore, IDisposable } from './lifecycle'; +import type { ServiceCollection } from './serviceCollection'; + +export type DependencyKind = 'instance' | 'collection' | 'ref'; + +export namespace _util { + export const serviceIds = new Map>(); + export const DI_TARGET = '$di$target'; + export const DI_DEPENDENCIES = '$di$dependencies'; + + export interface ServiceDependency { + readonly id: ServiceIdentifier; + readonly index: number; + readonly kind: DependencyKind; + } + + export function getServiceDependencies( + ctor: DI_TARGET_OBJ, + ): ServiceDependency[] { + return ctor[DI_DEPENDENCIES] || []; + } + + export function getInstanceDependencies( + ctor: DI_TARGET_OBJ, + ): ServiceDependency[] { + return getServiceDependencies(ctor).filter( + (dependency) => dependency.kind === 'instance', + ); + } + + export interface DI_TARGET_OBJ extends Function { + [DI_TARGET]: Function; + [DI_DEPENDENCIES]: { id: ServiceIdentifier; index: number; kind: DependencyKind }[]; + } +} + +export type BrandedService = { _serviceBrand: undefined }; + +export interface IConstructorSignature { + new (...args: [...Args, ...Services]): T; +} + +export type GetLeadingNonServiceArgs = + TArgs extends [] ? [] + : TArgs extends [...infer TFirst, BrandedService] ? GetLeadingNonServiceArgs + : TArgs; + +export interface ServiceIdentifier { + (target: any, key: string | symbol | undefined, index: number): void; + + readonly type: T; + + toString(): string; +} + +function storeServiceDependency( + id: ServiceIdentifier, + target: Function, + index: number, + kind: DependencyKind = 'instance', +): void { + const t = target as _util.DI_TARGET_OBJ; + if (t[_util.DI_TARGET] === target) { + t[_util.DI_DEPENDENCIES].push({ id, index, kind }); + } else { + t[_util.DI_DEPENDENCIES] = [{ id, index, kind }]; + t[_util.DI_TARGET] = target; + } +} + +export function storeCustomDependency( + id: ServiceIdentifier, + kind: DependencyKind, + target: any, + index: number, +): void { + storeServiceDependency(id, target, index, kind); +} + +export function createDecorator(name: string): ServiceIdentifier { + const existing = _util.serviceIds.get(name); + if (existing) { + return existing as ServiceIdentifier; + } + + const id = function serviceDecorator( + target: any, + _key: string | symbol | undefined, + index: number, + ): void { + if (arguments.length !== 3) { + throw new Error( + '@IServiceName-decorator can only be used to decorate a parameter', + ); + } + storeServiceDependency(id, target, index); + } as unknown as ServiceIdentifier; + + Object.defineProperty(id, 'toString', { + value: function toString(): string { + return name; + }, + enumerable: false, + writable: false, + configurable: false, + }); + Object.defineProperty(id, SERVICE_IDENTIFIER_MARK, { value: true, enumerable: false }); + + _util.serviceIds.set(name, id); + return id; +} + +const SERVICE_IDENTIFIER_MARK = Symbol('serviceIdentifier'); + +export function isServiceIdentifier(thing: unknown): thing is ServiceIdentifier { + return ( + typeof thing === 'function' && + (thing as unknown as Record)[SERVICE_IDENTIFIER_MARK] === true + ); +} + +export function refineServiceDecorator( + serviceIdentifier: ServiceIdentifier, +): ServiceIdentifier { + return serviceIdentifier as ServiceIdentifier; +} + +export enum ScopeActivation { + OnScopeCreated = 0, + OnDemand = 1, +} + +export interface LiveRef { + readonly current: T | undefined; + readonly onDidChange: Event; +} + +export function ref( + id: ServiceIdentifier, +): (target: object, key: string | symbol | undefined, index: number) => void { + return function refDecorator(target: any, _key: string | symbol | undefined, index: number): void { + if (arguments.length !== 3) { + throw new Error('@ref-decorator can only be used to decorate a parameter'); + } + storeServiceDependency(id, target, index, 'ref'); + }; +} + +export interface ServicesAccessor { + get(id: ServiceIdentifier): T; +} + +export interface ProvideOptions { + readonly activation?: 'eager' | 'ondemand'; + readonly config?: unknown; +} + +export interface ProvideAllEntry { + readonly id: ServiceIdentifier; + readonly descriptor: SyncDescriptor; + readonly options?: ProvideOptions; +} + +export interface ProvideHandle extends IDisposable { + readonly uid: number; +} + +export interface IInstantiationService { + readonly _serviceBrand: undefined; + + readonly cascade: CascadeEngine; + + invokeFunction( + fn: (accessor: ServicesAccessor, ...args: TS) => R, + ...args: TS + ): R; + createInstance(descriptor: SyncDescriptor0): T; + createInstance< + Ctor extends new ( + ...args: any[] + ) => unknown, + R extends InstanceType, + >( + ctor: Ctor, + ...args: GetLeadingNonServiceArgs> + ): R; + createChild(services: ServiceCollection, store?: DisposableStore): IInstantiationService; + provide( + id: ServiceIdentifier, + instanceOrDescriptor: T | SyncDescriptor, + options?: ProvideOptions, + ): ProvideHandle; + provideAll(entries: ReadonlyArray): void; + unprovide(id: ServiceIdentifier): void; + dispose(): void; + disposeAsync(): Promise; +} + +export const IInstantiationService: ServiceIdentifier = + createDecorator('instantiationService'); + +export interface ServiceCollectionLike { + set(id: ServiceIdentifier, instanceOrDescriptor: any): unknown; + get(id: ServiceIdentifier): any; + has(id: ServiceIdentifier): boolean; + forEach( + callback: (id: ServiceIdentifier, value: any) => void, + ): void; +} diff --git a/packages/agent-core-v2/src/_base/di/instantiationService.ts b/packages/agent-core-v2/src/_base/di/instantiationService.ts new file mode 100644 index 0000000000000000000000000000000000000000..5a32454d09be84b573396c8675e4ba693287b3d3 --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/instantiationService.ts @@ -0,0 +1,1006 @@ +import { SyncDescriptor } from './descriptors'; +import { CascadeEngine, CascadeTree, type CascadeChange, type CascadeHost } from './cascadeEngine'; +import { + CollectionStore, + type CollectionToken, + type CollectionView, + type CollectionViewImpl, +} from './collection'; +import { DependencyGraph } from './dependencyGraph'; +import { CascadeConflictError, CyclicDependencyError } from './errors'; +import { + bindServiceUnit, + isServiceRecipe, + pushConstructionFrame, + popConstructionFrame, + type ConstructionFrame, + type FiberHost, + type TokenProvideCore, + type UnitInternals, +} from './fiber'; +import { Graph } from './graph'; +import { + IInstantiationService as IInstantiationServiceDecorator, + _util, + type IInstantiationService, + type LiveRef, + type ProvideAllEntry, + type ProvideHandle, + type ProvideOptions, + type ServiceIdentifier, + type ServicesAccessor, +} from './instantiation'; +import { isDisposable, type DisposableStore, type IDisposable } from './lifecycle'; +import { onUnexpectedError } from '../errors/unexpectedError'; +import { Emitter } from '../event'; +import { Ledger, type LedgerEntry } from '../lifecycle/ledger'; +import type { Disposer } from '../lifecycle/disposer'; +import { ServiceCollection } from './serviceCollection'; + +const enum TraceType { + None = 0, + Creation = 1, + Invocation = 2, + Branch = 3, +} + +export class Trace { + static readonly all = new Set(); + + private static readonly _None = new class extends Trace { + constructor() { super(TraceType.None, null); } + override stop() { } + override branch() { return this; } + }; + + static traceInvocation(_enableTracing: boolean, fn: any): Trace { + return !_enableTracing + ? Trace._None + : new Trace( + TraceType.Invocation, + fn.name ?? new Error('Trace invocation').stack!.split('\n').slice(3, 4).join('\n'), + ); + } + + static traceCreation(_enableTracing: boolean, ctor: any): Trace { + return !_enableTracing ? Trace._None : new Trace(TraceType.Creation, ctor.name); + } + + private static _totals: number = 0; + private readonly _start: number = Date.now(); + private readonly _dep: [ServiceIdentifier, boolean, Trace?][] = []; + + private constructor( + readonly type: TraceType, + readonly name: string | null + ) { } + + branch(id: ServiceIdentifier, first: boolean): Trace { + const child = new Trace(TraceType.Branch, id.toString()); + this._dep.push([id, first, child]); + return child; + } + + stop() { + const dur = Date.now() - this._start; + Trace._totals += dur; + + let causedCreation = false; + + function printChild(n: number, trace: Trace) { + const res: string[] = []; + const prefix = '\t'.repeat(n); + for (const [id, first, child] of trace._dep) { + if (first && child) { + causedCreation = true; + res.push(`${prefix}CREATES -> ${String(id)}`); + const nested = printChild(n + 1, child); + if (nested) { + res.push(nested); + } + } else { + res.push(`${prefix}uses -> ${String(id)}`); + } + } + return res.join('\n'); + } + + const lines = [ + `${this.type === TraceType.Creation ? 'CREATE' : 'CALL'} ${this.name}`, + printChild(1, this), + `DONE, took ${dur.toFixed(2)}ms (grand total ${Trace._totals.toFixed(2)}ms)`, + ]; + + if (dur > 2 || causedCreation) { + Trace.all.add(lines.join('\n')); + } + } + +} + +export class InstantiationService implements IInstantiationService { + declare readonly _serviceBrand: undefined; + + readonly _globalGraph?: Graph; + + protected readonly _parent?: InstantiationService; + + protected readonly _ledger = new Ledger('InstantiationService'); + + get dependencyGraph(): DependencyGraph { + return this._tree.graph; + } + + private readonly _tree: CascadeTree; + + readonly cascade: CascadeEngine; + + private _parentLedgerEntry: LedgerEntry | undefined; + + private readonly _instanceEntries = new Map(); + + private readonly _provideEntries = new Map< + ServiceIdentifier, + { readonly entry: LedgerEntry; readonly core: TokenProvideCore } + >(); + + private _cascadeResolving = false; + + protected readonly _children = new Set(); + + private readonly _inProgress: ServiceIdentifier[] = []; + + private readonly _activeInstantiations = new Set>(); + + private readonly _collectionStore: CollectionStore; + + private readonly _collectionViews = new Map< + CollectionToken, + CollectionViewImpl + >(); + + private readonly _edgeNodes = new Set(); + + debugLabel: string | undefined; + + private _fiberHost: FiberHost | undefined; + + private _disposed = false; + + constructor( + private readonly _services: ServiceCollection = new ServiceCollection(), + private readonly _strict: boolean = false, + parent?: InstantiationService, + protected readonly _enableTracing: boolean = false, + ) { + this._parent = parent; + this._globalGraph = _enableTracing ? parent?._globalGraph ?? new Graph(e => e) : undefined; + this._services.set(IInstantiationServiceDecorator, this); + this._tree = parent?._tree ?? new CascadeTree(new DependencyGraph()); + this._collectionStore = + parent?._collectionStore ?? + new CollectionStore((container) => (container as InstantiationService)._parent); + const host: CascadeHost = { + isRegistered: (token) => this._getServiceInstanceOrDescriptor(token) !== undefined, + ownerScopeOf: (token) => this._ownerOf(token), + isMaterialized: (token) => { + const value = this._services.get(token); + return value !== undefined && !(value instanceof SyncDescriptor); + }, + materialize: (token) => { + this._cascadeResolving = true; + try { + return this._getOrCreateServiceInstance( + token, + Trace.traceCreation(false, CascadeEngine), + ); + } finally { + this._cascadeResolving = false; + } + }, + retire: (token) => this._retireUnit(token), + applyProvide: (token, descriptor, config) => { + this._services.set(token, descriptor, { config }); + return this._services.uidOf(token)!; + }, + applyProvideInstance: (token, instance, config) => { + this._services.set(token, instance, { config }); + return this._services.uidOf(token)!; + }, + applyUnprovide: (token) => { + this._services.delete(token); + }, + recipeOf: (token) => { + const entry = this._services.entry(token); + if (entry === undefined) return undefined; + return entry.value instanceof SyncDescriptor ? entry.value : entry.recipe; + }, + dependenciesOf: (recipe) => + _util.getInstanceDependencies(recipe.ctor).map((dependency) => dependency.id), + }; + this.cascade = new CascadeEngine(host, this, this._tree); + } + + get cascadeDisposed(): boolean { + return this._disposed; + } + + get cascadeDepth(): number { + return (this._parent?.cascadeDepth ?? -1) + 1; + } + + private _ownerOf(id: ServiceIdentifier): InstantiationService | undefined { + if (this._services.has(id)) { + return this; + } + return this._parent?._ownerOf(id); + } + + invokeFunction( + fn: (accessor: ServicesAccessor, ...args: TS) => R, + ...args: TS + ): R { + this._assertNotDisposed(); + const _trace = Trace.traceInvocation(this._enableTracing, fn); + let done = false; + try { + const accessor: ServicesAccessor = { + get: (id: ServiceIdentifier): T => { + if (done) { + throw new Error( + 'service accessor is only valid during the invocation of its target method', + ); + } + const result = this._getOrCreateServiceInstance(id, _trace); + if (!result) { + this._throwIfStrict(`[invokeFunction] unknown service '${String(id)}'`, false); + } + return result; + }, + }; + return fn(accessor, ...args); + } finally { + done = true; + _trace.stop(); + } + } + + provide( + id: ServiceIdentifier, + instanceOrDescriptor: T | SyncDescriptor, + options?: ProvideOptions, + ): ProvideHandle { + this._assertNotDisposed(); + const core = this._provideCore(id, instanceOrDescriptor, options); + const entry = this._ledger.register(() => { + void core.dispose(); + }, `provide:${String(id)}`); + this._provideEntries.set(id, { entry, core }); + return { + get uid(): number { + return core.uid(); + }, + dispose: () => { + void entry.dispose(); + }, + }; + } + + provideAll(entries: ReadonlyArray): void { + this._assertNotDisposed(); + const changes: CascadeChange[] = []; + for (const { id, descriptor, options } of entries) { + this._releaseProvideEntry(id); + changes.push({ + action: 'provide', + token: id, + descriptor, + activation: options?.activation, + config: options?.config, + reason: `provide ${String(id)}`, + }); + } + this.cascade.submitAll(changes).catch(onUnexpectedError); + for (const { id } of entries) { + const uid = this._services.uidOf(id); + if (uid === undefined) { + continue; + } + const entry = this._ledger.register(() => { + if (this._services.uidOf(id) === uid) { + void this._unprovideCore(id); + } + }, `provide:${String(id)}`); + this._provideEntries.set(id, { + entry, + core: { + uid: () => uid, + dispose: () => { + if (this._services.uidOf(id) === uid) { + return this._unprovideCore(id); + } + return Promise.resolve(); + }, + release: () => {}, + }, + }); + } + } + + private _provideCore( + id: ServiceIdentifier, + instanceOrDescriptor: T | SyncDescriptor, + options?: ProvideOptions, + ): TokenProvideCore { + this._releaseProvideEntry(id); + + if ( + !(instanceOrDescriptor instanceof SyncDescriptor) && + this._services.get(id) === instanceOrDescriptor + ) { + this._services.set(id, instanceOrDescriptor); + const uid = this._services.uidOf(id)!; + return { + uid: () => uid, + dispose: () => { + if (this._services.uidOf(id) === uid) { + return this._unprovideCore(id); + } + return Promise.resolve(); + }, + release: () => {}, + }; + } + + const beforeUid = this._services.uidOf(id); + let appliedUid: number | undefined; + const availability = this._services.onDidChange(id, ({ newUid }) => { + if (newUid !== undefined && newUid !== beforeUid) { + appliedUid ??= newUid; + } + }); + const release = (): void => { + availability.dispose(); + }; + const change: CascadeChange = + instanceOrDescriptor instanceof SyncDescriptor + ? { + action: 'provide', + token: id, + descriptor: instanceOrDescriptor, + activation: options?.activation, + config: options?.config, + reason: `provide ${String(id)}`, + } + : { + action: 'provide', + token: id, + instance: instanceOrDescriptor, + config: options?.config, + reason: `provide ${String(id)}`, + }; + this.cascade.submit(change).catch(onUnexpectedError); + return { + uid: () => { + if (appliedUid === undefined) { + throw new Error( + `provide of '${String(id)}' has not been applied yet (cascade in flight)`, + ); + } + return appliedUid; + }, + dispose: () => { + release(); + if (appliedUid !== undefined && this._services.uidOf(id) === appliedUid) { + return this._unprovideCore(id); + } + return Promise.resolve(); + }, + release, + }; + } + + private _unprovideCore(id: ServiceIdentifier): Promise { + if (this._disposed) { + return Promise.resolve(); + } + this._releaseProvideEntry(id); + if (this._services.get(id) === undefined) { + return Promise.resolve(); + } + return this.cascade + .submit({ action: 'unprovide', token: id, reason: `unprovide ${String(id)}` }) + .catch((error: unknown) => { + onUnexpectedError(error); + }); + } + + unprovide(id: ServiceIdentifier): void { + void this._unprovideCore(id); + } + + private _releaseProvideEntry(id: ServiceIdentifier): void { + const prev = this._provideEntries.get(id); + if (prev !== undefined) { + this._provideEntries.delete(id); + prev.entry.release(); + prev.core.release(); + } + } + + private _retireUnit(id: ServiceIdentifier): void | Promise { + const instance = this._services.get(id); + if (instance === undefined || instance instanceof SyncDescriptor) { + return undefined; + } + this._services.unmaterialize(id); + const entry = this._instanceEntries.get(instance); + if (entry === undefined) { + return undefined; + } + this._instanceEntries.delete(instance); + const serviceInstance = instance as object; + this._edgeNodes.delete(serviceInstance); + this._tree.graph.removeInstance(serviceInstance); + return entry.dispose(); + } + + private _nextUnitUid = 0; + + get fiberHost(): FiberHost { + return this._getFiberHost(); + } + + get collectionStore(): CollectionStore { + return this._collectionStore; + } + + get ledger(): Ledger { + return this._ledger; + } + + get cascadeTree(): CascadeTree { + return this._tree; + } + + get children(): readonly InstantiationService[] { + return [...this._children]; + } + + servicesSnapshot(): { token: string; uid: number }[] { + const snapshot: { token: string; uid: number }[] = []; + this._services.forEach((id) => { + snapshot.push({ + token: id.toString(), + uid: this._services.uidOf(id)!, + }); + }); + return snapshot; + } + + findIdentifier(tokenString: string): ServiceIdentifier | undefined { + let found: ServiceIdentifier | undefined; + this._services.forEach((id) => { + if (found === undefined && id.toString() === tokenString) { + found = id; + } + }); + return found; + } + + anchorKernelEntry(disposer: Disposer, label: string): LedgerEntry { + return this._ledger.register(disposer, label); + } + + anchorKernelFinalizer(disposer: Disposer, label: string): LedgerEntry { + return this._ledger.registerFinalizer(disposer, label); + } + + private _getFiberHost(): FiberHost { + this._fiberHost ??= { + mintUid: () => ++this._root()._nextUnitUid, + provideToken: (id, descriptor, options) => this._provideCore(id, descriptor, options), + provideTokenInstance: (id: ServiceIdentifier, instance: T) => + this._provideCore(id, instance, undefined), + tokenState: (id: ServiceIdentifier) => { + const owner = this._ownerOf(id) ?? this; + return owner.cascade.stateOf(id); + }, + updateToken: async (id, config, hasConfig) => { + const owner = this._ownerOf(id); + if (owner === undefined) { + throw new Error(`update of unregistered token '${String(id)}'`); + } + if (hasConfig) { + owner._services.setConfig(id, config); + } + await owner.cascade.update(id); + }, + resolveTokenWhenAvailable: (id: ServiceIdentifier): Promise => + this.cascade.resolveWhenAvailable(id), + resolveInstance: (id: ServiceIdentifier): T => + this.invokeFunction((accessor) => accessor.get(id)), + materializedInstance: (id: ServiceIdentifier): T | undefined => + this._materializedInstanceOf(id), + liveRef: (id: ServiceIdentifier): LiveRef => this._liveRef(id), + recordInstanceEdge: (node: object | undefined, id: ServiceIdentifier) => { + if (node === undefined) { + return; + } + const owner = this._ownerOf(id); + if (owner !== undefined) { + this._edgeNodes.add(node); + this.dependencyGraph.addEdge(node, { scope: owner, token: id }, 'instance'); + } + }, + collectionView: (token: CollectionToken): CollectionView => + this._collectionView(token), + addCollectionRecord: ( + token: CollectionToken, + providerName: string, + providerBook: Ledger, + value: T, + ) => + this._collectionStore.addRecord( + token, + this, + providerName, + this._scopePath(), + providerBook, + value, + ), + constructService: (ctor: new (...args: any[]) => T, config: unknown): T => { + return this._createInstance(ctor, [], Trace.traceCreation(this._enableTracing, ctor), { + config, + }); + }, + }; + return this._fiberHost; + } + + private _materializedInstanceOf(id: ServiceIdentifier): T | undefined { + const value = this._services.get(id); + if (value !== undefined) { + return value instanceof SyncDescriptor ? undefined : (value as T); + } + return this._parent?._materializedInstanceOf(id); + } + + private _liveRef(id: ServiceIdentifier): LiveRef { + const change = new Emitter(); + const chain: InstantiationService[] = [this]; + for (let c = this._parent; c !== undefined; c = c._parent) { + chain.push(c); + } + const subscriptions = chain.map((container) => + container._services.onDidChange(id, () => { + change.fire(); + }), + ); + this._ledger.register(() => { + for (const subscription of subscriptions) { + subscription.dispose(); + } + change.dispose(); + }, `ref:${String(id)}`); + const current = (): T | undefined => this._materializedInstanceOf(id); + const ref: LiveRef = { + get current(): T | undefined { + return current(); + }, + onDidChange: change.event, + }; + return ref; + } + + private _collectionView(token: CollectionToken): CollectionView { + let view = this._collectionViews.get(token as CollectionToken); + if (view === undefined) { + view = this._collectionStore.createView(token, this); + this._collectionViews.set(token as CollectionToken, view); + } + return view as CollectionViewImpl; + } + + private _scopePath(): string { + const labels: string[] = [this.debugLabel ?? `#${this._tree.seqOf(this)}`]; + for ( + let c: InstantiationService | undefined = this._parent; + c !== undefined; + c = c._parent + ) { + labels.unshift(c.debugLabel ?? `#${this._tree.seqOf(c)}`); + } + return labels.join('/'); + } + + createInstance(descriptor: SyncDescriptor, ...rest: any[]): T; + createInstance(ctor: new (...args: any[]) => T, ...rest: any[]): T; + createInstance( + ctorOrDescriptor: SyncDescriptor | (new (...args: any[]) => T), + ...rest: any[] + ): T { + this._assertNotDisposed(); + let _trace: Trace; + let result: T; + if (ctorOrDescriptor instanceof SyncDescriptor) { + _trace = Trace.traceCreation(this._enableTracing, ctorOrDescriptor.ctor); + result = this._createInstance( + ctorOrDescriptor.ctor, + ctorOrDescriptor.staticArguments.concat(rest), + _trace, + ); + } else { + _trace = Trace.traceCreation(this._enableTracing, ctorOrDescriptor); + result = this._createInstance(ctorOrDescriptor, rest, _trace); + } + _trace.stop(); + return result; + } + + createChild(services: ServiceCollection, store?: DisposableStore): IInstantiationService { + this._assertNotDisposed(); + if (!(services instanceof ServiceCollection)) { + throw new TypeError( + 'createChild requires a ServiceCollection instance (got something else)', + ); + } + const child = this._createChildService(services); + this._children.add(child); + child._parentLedgerEntry = this._ledger.register(() => { + child.dispose(); + }, 'child-instantiation'); + store?.add(child); + return child; + } + + protected _createChildService(services: ServiceCollection): InstantiationService { + return new InstantiationService(services, this._strict, this, this._enableTracing); + } + + private _disposePromise: Promise | undefined; + + dispose(): void { + void this.disposeAsync(); + } + + disposeAsync(): Promise { + this._disposePromise ??= this.disposeCore(); + return this._disposePromise; + } + + private disposeCore(): Promise { + if (this._disposed) { + return Promise.resolve(); + } + this._disposed = true; + + const childTeardowns: Promise[] = []; + let teardown: void | Promise = undefined; + try { + for (const child of Array.from(this._children)) { + childTeardowns.push(child.disposeAsync()); + } + this._children.clear(); + teardown = this._ledger.teardown('scope-close'); + this._services.dispose(); + this.cascade.dispose(); + for (const view of this._collectionViews.values()) { + view.dispose(); + } + this._collectionViews.clear(); + for (const node of this._edgeNodes) { + this._tree.graph.removeInstance(node); + } + this._edgeNodes.clear(); + this._tree.graph.removeScope(this); + } finally { + this._children.clear(); + this._parentLedgerEntry?.release(); + this._parentLedgerEntry = undefined; + if (this._parent) { + this._parent._children.delete(this); + } + } + return Promise.all([...childTeardowns, Promise.resolve(teardown)]).then(() => undefined); + } + + private _createInstance(ctor: any, args: unknown[], _trace: Trace, unit?: { + id?: ServiceIdentifier; + config?: unknown; + }): T { + const serviceDependencies = _util.getServiceDependencies(ctor).toSorted((a, b) => a.index - b.index); + const serviceArgs: unknown[] = []; + for (const dependency of serviceDependencies) { + const kind = dependency.kind ?? 'instance'; + if (kind === 'collection') { + serviceArgs.push(this._collectionView(dependency.id as unknown as CollectionToken)); + continue; + } + if (kind === 'ref') { + serviceArgs.push(this._liveRef(dependency.id)); + continue; + } + const service = this._getOrCreateServiceInstance(dependency.id, _trace); + if (!service) { + this._throwIfStrict( + `[createInstance] ${ctor.name} depends on UNKNOWN service ${String(dependency.id)}.`, + false, + ); + } + serviceArgs.push(service); + } + + const firstServiceArgPos = + serviceDependencies.length > 0 ? serviceDependencies[0]!.index : args.length; + + if (args.length !== firstServiceArgPos) { + globalThis.console.trace( + `[createInstance] First service dependency of ${(ctor as { name?: string }).name} at position ${firstServiceArgPos + 1} conflicts with ${args.length} static arguments`, + ); + const delta = firstServiceArgPos - args.length; + if (delta > 0) { + args = args.concat(Array.from({ length: delta })); + } else { + args = args.slice(0, firstServiceArgPos); + } + } + + const finalArgs = args.concat(serviceArgs); + if (!isServiceRecipe(ctor)) { + return Reflect.construct(ctor, finalArgs); + } + const frame: ConstructionFrame = { + ctor, + config: unit?.config, + token: unit?.id, + host: this._getFiberHost(), + }; + pushConstructionFrame(frame); + let instance: T; + try { + instance = Reflect.construct(ctor as new (...args: any[]) => T, finalArgs); + } finally { + popConstructionFrame(); + } + bindServiceUnit(instance as UnitInternals & IDisposable, frame); + return instance; + } + + protected _getOrCreateServiceInstance(id: ServiceIdentifier, _trace: Trace): T { + if (!this._cascadeResolving) { + if (this.cascade.isInFlight(id)) { + throw new CascadeConflictError( + String(id), + 'token is inside an in-flight cascade transaction', + ); + } + const failure = this.cascade.failureOf(id); + if (failure !== undefined) { + throw failure as Error; + } + } + const entry = this._getServiceInstanceOrDescriptor(id); + + if (entry instanceof SyncDescriptor) { + const root = this._root(); + if (root._inProgress.includes(id)) { + const path = [...root._inProgress, id].map(String); + throw new CyclicDependencyError(path); + } + + return this._safeCreateAndCacheServiceInstance(id, entry, _trace.branch(id, true)); + } + + _trace.branch(id, false); + return entry as T; + } + + private _safeCreateAndCacheServiceInstance( + id: ServiceIdentifier, + desc: SyncDescriptor, + _trace: Trace, + ): T { + if (this._activeInstantiations.has(id)) { + throw new Error(`illegal state - RECURSIVELY instantiating service '${String(id)}'`); + } + this._activeInstantiations.add(id); + try { + return this._createAndCacheServiceInstance(id, desc, _trace); + } finally { + this._activeInstantiations.delete(id); + } + } + + private _createAndCacheServiceInstance( + id: ServiceIdentifier, + desc: SyncDescriptor, + _trace: Trace, + ): T { + type Triple = { id: ServiceIdentifier; desc: SyncDescriptor; _trace: Trace }; + const graph = new Graph(data => data.id.toString()); + + let cycleCount = 0; + const stack: Triple[] = [{ id, desc, _trace }]; + const seen = new Set(); + while (stack.length > 0) { + const item = stack.pop()!; + + if (seen.has(String(item.id))) { + continue; + } + seen.add(String(item.id)); + + graph.lookupOrInsertNode(item); + + if (cycleCount++ > 1000) { + throw new CyclicDependencyError(graph); + } + + for (const dependency of _util.getInstanceDependencies(item.desc.ctor)) { + const instanceOrDesc = this._getServiceInstanceOrDescriptor(dependency.id); + if (!instanceOrDesc) { + this._throwIfStrict( + `[createInstance] ${String(item.id)} depends on ${String(dependency.id)} which is NOT registered.`, + true, + ); + } + + this._globalGraph?.insertEdge(String(item.id), String(dependency.id)); + + if (instanceOrDesc instanceof SyncDescriptor) { + const d: Triple = { + id: dependency.id, + desc: instanceOrDesc, + _trace: item._trace.branch(dependency.id, true), + }; + graph.insertEdge(item, d); + stack.push(d); + } + } + } + + while (true) { + const roots = graph.roots(); + + if (roots.length === 0) { + if (!graph.isEmpty()) { + throw new CyclicDependencyError(graph); + } + break; + } + + for (const { data } of roots) { + const instanceOrDesc = this._getServiceInstanceOrDescriptor(data.id); + if (instanceOrDesc instanceof SyncDescriptor) { + const instance = this._createServiceInstanceWithOwner( + data.id, + data.desc.ctor, + data.desc.staticArguments, + data._trace, + ); + this._setCreatedServiceInstance(data.id, instance); + } + graph.removeNode(data); + } + } + return this._getServiceInstanceOrDescriptor(id) as T; + } + + private _createServiceInstanceWithOwner( + id: ServiceIdentifier, + ctor: any, + args: ReadonlyArray = [], + _trace: Trace, + ): T { + if (this._services.get(id) instanceof SyncDescriptor) { + return this._createServiceInstance(id, ctor, args, _trace); + } + if (this._parent) { + return this._parent._createServiceInstanceWithOwner( + id, + ctor, + args, + _trace, + ); + } + throw new Error(`illegalState - creating UNKNOWN service instance ${ctor.name}`); + } + + private _createServiceInstance( + id: ServiceIdentifier, + ctor: any, + args: ReadonlyArray = [], + _trace: Trace, + ): T { + const root = this._root(); + root._inProgress.push(id); + try { + const result = this._createInstance(ctor, args.slice(), _trace, { + id, + config: this._services.configOf(id), + }); + this.dependencyGraph.addInstance(result as object, this, id); + for (const dependency of _util.getServiceDependencies(ctor)) { + const kind = dependency.kind ?? 'instance'; + if (kind === 'collection') { + this.dependencyGraph.addEdge( + result as object, + { scope: this, token: dependency.id }, + 'collection', + ); + continue; + } + if (kind === 'ref') { + continue; + } + const owner = this._ownerOf(dependency.id); + if (owner !== undefined) { + this.dependencyGraph.addEdge( + result as object, + { scope: owner, token: dependency.id }, + 'instance', + ); + } + } + const entry = this._ledger.register(() => { + this._instanceEntries.delete(result); + this.dependencyGraph.removeInstance(result as object); + if (isDisposable(result)) { + const out = result.dispose() as unknown as void | Promise; + return out; + } + return undefined; + }, `service:${String(id)}`); + this._instanceEntries.set(result, entry); + this.cascade.observedMaterialization(id); + return result; + } finally { + const popIdx = root._inProgress.lastIndexOf(id); + if (popIdx >= 0) { + root._inProgress.splice(popIdx, 1); + } + } + } + + private _setCreatedServiceInstance(id: ServiceIdentifier, instance: T): void { + if (this._services.get(id) instanceof SyncDescriptor) { + this._services.materialize(id, instance); + } else if (this._parent) { + this._parent._setCreatedServiceInstance(id, instance); + } else { + throw new Error( + `illegal state - setting UNKNOWN service instance '${String(id)}'`, + ); + } + } + + private _getServiceInstanceOrDescriptor( + id: ServiceIdentifier, + ): T | SyncDescriptor | undefined { + const instanceOrDesc = this._services.get(id); + if (instanceOrDesc === undefined && this._parent) { + return this._parent._getServiceInstanceOrDescriptor(id); + } + return instanceOrDesc; + } + + private _throwIfStrict(msg: string, printWarning: boolean): void { + if (printWarning) { + globalThis.console.warn(msg); + } + if (this._strict) { + throw new Error(msg); + } + } + + private _root(): InstantiationService { + return this._parent?._root() ?? this; + } + + private _assertNotDisposed(): void { + if (this._disposed) { + throw new Error('InstantiationService has been disposed'); + } + } +} diff --git a/packages/agent-core-v2/src/_base/di/lifecycle.ts b/packages/agent-core-v2/src/_base/di/lifecycle.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc5020698a07eb5edea38271f395599b497f29c6 --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/lifecycle.ts @@ -0,0 +1,709 @@ +import { onUnexpectedError } from '../errors/unexpectedError'; +import { Ledger, type LedgerEntry } from '../lifecycle/ledger'; + +export interface IDisposableDebugLabel { + readonly debugLabel?: string; +} + +function disposableLabel(d: IDisposable): string { + const debugLabel = (d as IDisposableDebugLabel).debugLabel; + if (typeof debugLabel === 'string' && debugLabel.length > 0) { + return debugLabel; + } + return `disposable:${d.constructor?.name ?? 'anonymous'}`; +} + +export interface IDisposableTracker { + trackDisposable(disposable: IDisposable): void; + setParent(child: IDisposable, parent: IDisposable | null): void; + markAsDisposed(disposable: IDisposable): void; + markAsSingleton(disposable: IDisposable): void; +} + +interface DisposableInfo { + value: IDisposable; + source: string | null; + parent: IDisposable | null; + isSingleton: boolean; + idx: number; +} + +export class DisposableTracker implements IDisposableTracker { + private static idx = 0; + private readonly livingDisposables = new Map(); + + private getDisposableData(d: IDisposable): DisposableInfo { + let val = this.livingDisposables.get(d); + if (!val) { + val = { + parent: null, + source: null, + isSingleton: false, + value: d, + idx: DisposableTracker.idx++, + }; + this.livingDisposables.set(d, val); + } + return val; + } + + trackDisposable(d: IDisposable): void { + const data = this.getDisposableData(d); + data.source ??= new Error('Disposable tracking').stack ?? null; + } + + setParent(child: IDisposable, parent: IDisposable | null): void { + this.getDisposableData(child).parent = parent; + } + + markAsDisposed(x: IDisposable): void { + this.livingDisposables.delete(x); + } + + markAsSingleton(d: IDisposable): void { + this.getDisposableData(d).isSingleton = true; + } + + private getRootParent( + data: DisposableInfo, + cache: Map, + ): DisposableInfo { + const cached = cache.get(data); + if (cached) return cached; + const result = data.parent + ? this.getRootParent(this.getDisposableData(data.parent), cache) + : data; + cache.set(data, result); + return result; + } + + getTrackedDisposables(): IDisposable[] { + const cache = new Map(); + return [...this.livingDisposables.entries()] + .filter( + ([, v]) => v.source !== null && !this.getRootParent(v, cache).isSingleton, + ) + .map(([k]) => k); + } +} + +let disposableTracker: IDisposableTracker | null = null; + +export function setDisposableTracker(tracker: IDisposableTracker | null): void { + disposableTracker = tracker; +} + +export function trackDisposable(x: T): T { + disposableTracker?.trackDisposable(x); + return x; +} + +export function markAsDisposed(disposable: IDisposable): void { + disposableTracker?.markAsDisposed(disposable); +} + +function setParentOfDisposable( + child: IDisposable, + parent: IDisposable | null, +): void { + disposableTracker?.setParent(child, parent); +} + +function setParentOfDisposables( + children: IDisposable[], + parent: IDisposable | null, +): void { + if (!disposableTracker) return; + for (const child of children) { + disposableTracker.setParent(child, parent); + } +} + +export function markAsSingleton(singleton: T): T { + disposableTracker?.markAsSingleton(singleton); + return singleton; +} + +export interface IDisposable { + dispose(): void; +} + +export function isDisposable(thing: E): thing is E & IDisposable { + return ( + typeof thing === 'object' && + thing !== null && + typeof (thing as unknown as IDisposable).dispose === 'function' && + (thing as unknown as IDisposable).dispose.length === 0 + ); +} + +export function dispose(disposable: T): T; +export function dispose( + disposable: T | undefined, +): T | undefined; +export function dispose = Iterable>( + disposables: A, +): A; +export function dispose(disposables: Array): Array; +export function dispose( + disposables: ReadonlyArray, +): ReadonlyArray; +export function dispose( + arg: T | Iterable | undefined, +): unknown { + if (arg === undefined || arg === null) return arg; + if (isIterable(arg)) { + const errors: unknown[] = []; + for (const d of arg) { + if (d) { + try { + d.dispose(); + } catch (error) { + errors.push(error); + } + } + } + + if (errors.length === 1) { + throw errors[0]; + } + if (errors.length > 1) { + throw new AggregateError( + errors, + 'Encountered errors while disposing of store', + ); + } + + return Array.isArray(arg) ? [] : arg; + } + (arg).dispose(); + return arg; +} + +function isIterable(arg: unknown): arg is Iterable { + return ( + typeof arg === 'object' && + arg !== null && + typeof (arg as { [Symbol.iterator]?: unknown })[Symbol.iterator] === 'function' + ); +} + +export function disposeIfDisposable( + disposables: Array, +): Array { + const disposableValues: IDisposable[] = []; + for (const d of disposables) { + if (isDisposable(d)) { + disposableValues.push(d); + } + } + dispose(disposableValues); + return []; +} + +class FunctionDisposable implements IDisposable { + private _isDisposed = false; + private readonly _fn: () => void; + + constructor(fn: () => void) { + this._fn = fn; + trackDisposable(this); + } + + dispose(): void { + if (this._isDisposed) return; + this._isDisposed = true; + markAsDisposed(this); + this._fn(); + } +} + +export function toDisposable(fn: () => void): IDisposable { + return new FunctionDisposable(fn); +} + +export function combinedDisposable(...disposables: IDisposable[]): IDisposable { + const parent = toDisposable(() => dispose(disposables)); + setParentOfDisposables(disposables, parent); + return parent; +} + +export class DisposableStore implements IDisposable { + private readonly _ledger = new Ledger('DisposableStore'); + private readonly _entries = new Map(); + private _isDisposed = false; + + constructor() { + trackDisposable(this); + } + + get ledger(): Ledger { + return this._ledger; + } + + add(d: T): T { + if ((d as unknown as DisposableStore) === this) { + throw new Error('Cannot register a disposable on itself!'); + } + setParentOfDisposable(d, this); + if (this._isDisposed) { + d.dispose(); + return d; + } + if (!this._entries.has(d)) { + this._entries.set( + d, + this._ledger.register(() => { + d.dispose(); + }, disposableLabel(d)), + ); + } + return d; + } + + delete(d: T): void { + if (this._isDisposed) return; + if ((d as unknown as DisposableStore) === this) { + throw new Error('Cannot dispose a disposable on itself!'); + } + const entry = this._entries.get(d); + if (entry) { + this._entries.delete(d); + entry.release(); + } + d.dispose(); + } + + deleteAndLeak(d: T): void { + if (this._isDisposed) return; + const entry = this._entries.get(d); + if (entry) { + this._entries.delete(d); + entry.release(); + setParentOfDisposable(d, null); + } + } + + clear(): void { + if (this._entries.size === 0) return; + try { + void this._ledger.clear('scope-close'); + } finally { + this._entries.clear(); + } + } + + dispose(): void { + if (this._isDisposed) return; + this._isDisposed = true; + markAsDisposed(this); + try { + void this._ledger.teardown('scope-close'); + } finally { + this._entries.clear(); + } + } + + get isDisposed(): boolean { + return this._isDisposed; + } + + assertNotDisposed(): void { + if (this._isDisposed) { + onUnexpectedError(new Error('Object disposed')); + } + } +} + +export abstract class Disposable implements IDisposable { + protected readonly _store = new DisposableStore(); + + constructor() { + trackDisposable(this); + setParentOfDisposable(this._store, this); + } + + protected _register(d: T): T { + if ((d as unknown as Disposable) === this) { + throw new Error('Cannot register a disposable on itself!'); + } + return this._store.add(d); + } + + dispose(): void { + markAsDisposed(this); + this._store.dispose(); + } +} + +export namespace Disposable { + export const None: IDisposable = Object.freeze({ + dispose(): void {}, + }); +} + +export class MutableDisposable implements IDisposable { + private readonly _ledger = new Ledger('MutableDisposable'); + private _entry: LedgerEntry | undefined; + private _value: T | undefined; + private _isDisposed = false; + + constructor() { + trackDisposable(this); + } + + get value(): T | undefined { + return this._isDisposed ? undefined : this._value; + } + + set value(value: T | undefined) { + if (this._isDisposed) { + if (value !== undefined) { + value.dispose(); + } + return; + } + if (this._value === value) return; + this._entry?.release(); + this._entry = undefined; + this._value?.dispose(); + if (value) setParentOfDisposable(value, this); + this._value = value; + if (value) { + this._entry = this._ledger.register(() => { + value.dispose(); + }, disposableLabel(value)); + } + } + + dispose(): void { + if (this._isDisposed) return; + this._isDisposed = true; + markAsDisposed(this); + const entry = this._entry; + const prev = this._value; + this._entry = undefined; + this._value = undefined; + entry?.release(); + void this._ledger.teardown('scope-close'); + if (prev !== undefined) { + prev.dispose(); + } + } + + clear(): void { + if (this._isDisposed) return; + this.value = undefined; + } + + clearAndLeak(): T | undefined { + if (this._isDisposed) return undefined; + const prev = this._value; + this._value = undefined; + this._entry?.release(); + this._entry = undefined; + if (prev !== undefined) setParentOfDisposable(prev, null); + return prev; + } +} + +export class MandatoryMutableDisposable implements IDisposable { + private readonly _disposable = new MutableDisposable(); + private _isDisposed = false; + + constructor(initialValue: T) { + this._disposable.value = initialValue; + } + + get value(): T { + return this._disposable.value!; + } + + set value(value: T) { + if (this._isDisposed || value === this._disposable.value) return; + this._disposable.value = value; + } + + dispose(): void { + if (this._isDisposed) return; + this._isDisposed = true; + this._disposable.dispose(); + } +} + +export class RefCountedDisposable { + private _counter = 1; + + constructor(private readonly _disposable: IDisposable) {} + + acquire(): this { + this._counter += 1; + return this; + } + + release(): this { + this._counter -= 1; + if (this._counter === 0) { + this._disposable.dispose(); + } + return this; + } +} + +export interface IReference extends IDisposable { + readonly object: T; +} + +export abstract class ReferenceCollection { + private readonly references = new Map< + string, + { readonly object: T; counter: number } + >(); + + acquire(key: string, ...args: unknown[]): IReference { + let reference = this.references.get(key); + if (!reference) { + reference = { + counter: 0, + object: this.createReferencedObject(key, ...args), + }; + this.references.set(key, reference); + } + + const { object } = reference; + let disposed = false; + const dispose = () => { + if (disposed) return; + disposed = true; + reference.counter -= 1; + if (reference.counter === 0) { + this.destroyReferencedObject(key, reference.object); + this.references.delete(key); + } + }; + + reference.counter += 1; + return { object, dispose }; + } + + protected abstract createReferencedObject(key: string, ...args: unknown[]): T; + protected abstract destroyReferencedObject(key: string, object: T): void; +} + +export class AsyncReferenceCollection { + constructor(private readonly referenceCollection: ReferenceCollection>) {} + + async acquire(key: string, ...args: unknown[]): Promise> { + const ref = this.referenceCollection.acquire(key, ...args); + + try { + const object = await ref.object; + return { + object, + dispose: () => { ref.dispose(); }, + }; + } catch (error) { + ref.dispose(); + throw error; + } + } +} + +export class ImmortalReference implements IReference { + constructor(public readonly object: T) {} + dispose(): void {} +} + +export class DisposableMap + implements IDisposable +{ + private readonly _store: Map; + private _isDisposed = false; + + constructor(store: Map = new Map()) { + this._store = store; + trackDisposable(this); + } + + dispose(): void { + if (this._isDisposed) return; + this._isDisposed = true; + markAsDisposed(this); + this.clearAndDisposeAll(); + } + + clearAndDisposeAll(): void { + if (this._store.size === 0) return; + try { + dispose(this._store.values()); + } finally { + this._store.clear(); + } + } + + has(key: K): boolean { + return this._store.has(key); + } + + get size(): number { + return this._store.size; + } + + get(key: K): V | undefined { + return this._store.get(key); + } + + set(key: K, value: V, skipDisposeOnOverwrite = false): void { + if (this._isDisposed) { + console.warn( + new Error( + 'Trying to add a disposable to a DisposableMap that has already been disposed of. The added object will be leaked!', + ).stack, + ); + return; + } + if (!skipDisposeOnOverwrite) { + const prev = this._store.get(key); + if (prev !== undefined && prev !== value) { + prev.dispose(); + } + } + this._store.set(key, value); + setParentOfDisposable(value, this); + } + + deleteAndDispose(key: K): void { + const value = this._store.get(key); + if (value !== undefined) { + value.dispose(); + } + this._store.delete(key); + } + + deleteAndLeak(key: K): V | undefined { + const value = this._store.get(key); + if (value !== undefined) setParentOfDisposable(value, null); + this._store.delete(key); + return value; + } + + keys(): IterableIterator { + return this._store.keys(); + } + + values(): IterableIterator { + return this._store.values(); + } + + [Symbol.iterator](): IterableIterator<[K, V]> { + return this._store[Symbol.iterator](); + } +} + +export class DisposableSet + implements IDisposable +{ + private readonly _store: Set; + private _isDisposed = false; + + constructor(store: Set = new Set()) { + this._store = store; + trackDisposable(this); + } + + dispose(): void { + if (this._isDisposed) return; + this._isDisposed = true; + markAsDisposed(this); + this.clearAndDisposeAll(); + } + + clearAndDisposeAll(): void { + if (this._store.size === 0) return; + try { + dispose(this._store.values()); + } finally { + this._store.clear(); + } + } + + has(value: V): boolean { + return this._store.has(value); + } + + get size(): number { + return this._store.size; + } + + add(value: V): void { + if (this._isDisposed) { + console.warn( + new Error( + 'Trying to add a disposable to a DisposableSet that has already been disposed of. The added object will be leaked!', + ).stack, + ); + return; + } + this._store.add(value); + setParentOfDisposable(value, this); + } + + deleteAndDispose(value: V): void { + if (this._store.delete(value)) { + value.dispose(); + } + } + + deleteAndLeak(value: V): V | undefined { + if (this._store.delete(value)) { + setParentOfDisposable(value, null); + return value; + } + return undefined; + } + + values(): IterableIterator { + return this._store.values(); + } + + [Symbol.iterator](): IterableIterator { + return this._store[Symbol.iterator](); + } +} + +export function disposeOnReturn(fn: (store: DisposableStore) => void): void { + const store = new DisposableStore(); + try { + fn(store); + } finally { + store.dispose(); + } +} + +export function thenIfNotDisposed( + promise: Promise, + then: (result: T) => void, +): IDisposable { + let disposed = false; + void promise.then((result) => { + if (disposed) return; + then(result); + }); + return toDisposable(() => { + disposed = true; + }); +} + +export function thenRegisterOrDispose( + promise: Promise, + store: DisposableStore, +): Promise { + return promise.then((disposable) => { + if (store.isDisposed) { + disposable.dispose(); + } else { + store.add(disposable); + } + return disposable; + }); +} diff --git a/packages/agent-core-v2/src/_base/di/scope.ts b/packages/agent-core-v2/src/_base/di/scope.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d15ba84964306180456d8667c28944328c1d0fd --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/scope.ts @@ -0,0 +1,300 @@ +import { BugIndicatingError } from '../errors/errors'; +import { SyncDescriptor } from './descriptors'; +import { ScopeActivation, type ProvideAllEntry } from './instantiation'; +import type { ServiceIdentifier, ServicesAccessor, IInstantiationService } from './instantiation'; +import { InstantiationService } from './instantiationService'; +import { DisposableStore, type IDisposable } from './lifecycle'; +import { Ledger, type LedgerEntry } from '../lifecycle/ledger'; +import { ServiceCollection } from './serviceCollection'; +import { watchScopeUnits } from './scopeUnits'; + +export { ScopeActivation }; + +export type ScopeKind = string; + +let _scopeTopology: readonly string[] | undefined; + +export function setScopeTopology(kinds: readonly string[]): void { + if (_scopeTopology === undefined) { + _scopeTopology = [...kinds]; + return; + } + if ( + _scopeTopology.length === kinds.length && + _scopeTopology.every((kind, index) => kind === kinds[index]) + ) { + return; + } + throw new BugIndicatingError( + `scope topology already declared as [${_scopeTopology.join(', ')}]; cannot redeclare as [${kinds.join(', ')}]`, + ); +} + +export interface ScopedEntry { + readonly scope: ScopeKind; + readonly id: ServiceIdentifier; + readonly descriptor: SyncDescriptor; + readonly domain: string; + readonly activation: ScopeActivation; +} + +const _scopedRegistry: ScopedEntry[] = []; + +function findScopedEntryIndex(scope: ScopeKind, id: ServiceIdentifier): number { + return _scopedRegistry.findIndex((entry) => entry.scope === scope && entry.id === id); +} + +export function registerScopedService( + scope: ScopeKind, + id: ServiceIdentifier, + ctor: new (...args: any[]) => T, + activation: ScopeActivation = ScopeActivation.OnScopeCreated, + domain: string = 'unknown', +): void { + const existing = findScopedEntryIndex(scope, id as ServiceIdentifier); + if (existing !== -1) { + throw new BugIndicatingError( + `duplicate scoped service registration for '${String(id)}' in scope '${scope}' (registered domain '${_scopedRegistry[existing]?.domain}', attempted domain '${domain}'); use overrideScopedService for intentional replacement`, + ); + } + const descriptor = new SyncDescriptor(ctor); + _scopedRegistry.push({ + scope, + id: id as ServiceIdentifier, + descriptor: descriptor as SyncDescriptor, + domain, + activation, + }); +} + +export function overrideScopedService( + scope: ScopeKind, + id: ServiceIdentifier, + ctor: new (...args: any[]) => T, + activation: ScopeActivation = ScopeActivation.OnScopeCreated, + domain: string = 'unknown', +): void { + const index = findScopedEntryIndex(scope, id as ServiceIdentifier); + if (index === -1) { + throw new BugIndicatingError( + `overrideScopedService found no registration for '${String(id)}' in scope '${scope}' (domain '${domain}'); use registerScopedService for the initial registration`, + ); + } + const descriptor = new SyncDescriptor(ctor); + _scopedRegistry[index] = { + scope, + id: id as ServiceIdentifier, + descriptor: descriptor as SyncDescriptor, + domain, + activation, + }; +} + +export function getScopedServiceDescriptors(scope: ScopeKind): ReadonlyArray { + return _scopedRegistry.filter((entry) => entry.scope === scope); +} + +export function _clearScopedRegistryForTests(): void { + _scopedRegistry.length = 0; +} + +export type ScopeSeed = ReadonlyArray< + readonly [ServiceIdentifier, unknown] +>; + +export interface ScopeOptions { + readonly id?: string; + readonly seeds?: ScopeSeed; + readonly configureContainer?: (container: InstantiationService) => void; +} + +export interface IScopeHandle { + readonly id: string; + readonly kind: K; + readonly accessor: ServicesAccessor; + dispose(): void | Promise; +} + +export type IAppScopeHandle = IScopeHandle<'app'>; +export type ISessionScopeHandle = IScopeHandle<'session'>; +export type IAgentScopeHandle = IScopeHandle<'agent'>; + +function buildCollection(seeds?: ScopeSeed): ServiceCollection { + const collection = new ServiceCollection(); + if (seeds) { + for (const [id, value] of seeds) { + collection.set(id, value); + } + } + return collection; +} + +function provideScopeServices( + instantiation: IInstantiationService, + kind: ScopeKind, + collection: ServiceCollection, +): void { + const entries: ProvideAllEntry[] = []; + for (const entry of _scopedRegistry) { + if (entry.scope !== kind || collection.get(entry.id) !== undefined) { + continue; + } + entries.push({ + id: entry.id, + descriptor: entry.descriptor, + options: { + activation: entry.activation === ScopeActivation.OnDemand ? 'ondemand' : 'eager', + }, + }); + } + instantiation.provideAll(entries); +} + +export function createScopedChildHandle( + parent: IInstantiationService, + kind: ScopeKind, + id: string, + options: ScopeOptions = {}, +): IScopeHandle { + const collection = buildCollection(options.seeds); + const child = parent.createChild(collection); + (child as InstantiationService).debugLabel = id; + const engine = (child as InstantiationService).cascade; + engine.suspendActivation(); + try { + watchScopeUnits(child as InstantiationService, kind); + options.configureContainer?.(child as InstantiationService); + provideScopeServices(child, kind, collection); + } catch (error) { + child.dispose(); + throw error; + } finally { + engine.resumeActivation(); + } + const accessor: ServicesAccessor = { + get: (serviceId: ServiceIdentifier): T => + child.invokeFunction((a) => a.get(serviceId)), + }; + return { id, kind, accessor, dispose: () => child.disposeAsync() }; +} + +export class Scope implements IDisposable { + readonly children = new Map(); + readonly accessor: ServicesAccessor; + + private readonly _store = new DisposableStore(); + private readonly _ledger: Ledger; + private _ledgerEntry: LedgerEntry | undefined; + private _disposed = false; + + private constructor( + readonly id: string, + readonly kind: ScopeKind, + readonly instantiation: IInstantiationService, + private readonly _parent?: Scope, + ) { + this._ledger = new Ledger(`scope:${id}`); + this._ledger.register(() => { + this.instantiation.dispose(); + }, 'instantiation'); + this._ledger.register(() => { + this._store.dispose(); + }, 'store'); + this.accessor = { + get: (serviceId: ServiceIdentifier): T => + instantiation.invokeFunction((a) => a.get(serviceId)), + }; + } + + get ledger(): Ledger { + return this._ledger; + } + + static createApp(options: ScopeOptions = {}): Scope { + const kind: ScopeKind = 'app'; + const collection = buildCollection(options.seeds); + const instantiation = new InstantiationService(collection, true); + instantiation.debugLabel = options.id ?? 'app'; + instantiation.cascade.suspendActivation(); + try { + watchScopeUnits(instantiation, kind); + options.configureContainer?.(instantiation); + provideScopeServices(instantiation, kind, collection); + } catch (error) { + instantiation.dispose(); + throw error; + } finally { + instantiation.cascade.resumeActivation(); + } + return new Scope(options.id ?? 'app', kind, instantiation); + } + + private _assertNotDisposed(): void { + if (this._disposed) { + throw new Error(`Scope '${this.id}' has been disposed`); + } + } + + createChild(kind: ScopeKind, id: string, options: ScopeOptions = {}): Scope { + this._assertNotDisposed(); + if (_scopeTopology !== undefined) { + const parentIndex = _scopeTopology.indexOf(this.kind); + const childIndex = _scopeTopology.indexOf(kind); + if (parentIndex === -1 || childIndex === -1 || childIndex <= parentIndex) { + throw new Error( + `child scope kind '${kind}' must be greater than parent kind '${this.kind}' in the declared scope topology`, + ); + } + } + if (this.children.has(id)) { + throw new Error(`Scope '${this.id}' already has a child with id '${id}'`); + } + const collection = buildCollection(options.seeds); + const childInstantiation = this.instantiation.createChild(collection); + (childInstantiation as InstantiationService).debugLabel = id; + const engine = (childInstantiation as InstantiationService).cascade; + engine.suspendActivation(); + try { + watchScopeUnits(childInstantiation as InstantiationService, kind); + options.configureContainer?.(childInstantiation as InstantiationService); + provideScopeServices(childInstantiation, kind, collection); + } catch (error) { + childInstantiation.dispose(); + throw error; + } finally { + engine.resumeActivation(); + } + const child = new Scope(id, kind, childInstantiation, this); + this.children.set(id, child); + child._ledgerEntry = this._ledger.register(() => { + child.dispose(); + }, `scope:${id}`); + return child; + } + + toHandle(): IScopeHandle { + return { id: this.id, kind: this.kind, accessor: this.accessor, dispose: () => this.dispose() }; + } + + dispose(): void { + if (this._disposed) { + return; + } + this._disposed = true; + + this._ledgerEntry?.release(); + this._ledgerEntry = undefined; + try { + void this._ledger.teardown('scope-close'); + } finally { + this.children.clear(); + if (this._parent) { + this._parent.children.delete(this.id); + } + } + } +} + +export function createAppScope(options: ScopeOptions = {}): Scope { + return Scope.createApp(options); +} diff --git a/packages/agent-core-v2/src/_base/di/scopeUnits.ts b/packages/agent-core-v2/src/_base/di/scopeUnits.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f02b119995b14f9b71d8b9cde9f8827d95d915d --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/scopeUnits.ts @@ -0,0 +1,110 @@ +import { onUnexpectedError } from '../errors/unexpectedError'; +import type { IDisposable } from './lifecycle'; +import { Ledger, type LedgerEntry } from '../lifecycle/ledger'; +import type { StoredRecord } from './collection'; +import { + FiberRuntime, + isClassRecipe, + ScopeUnits, + type EffectBody, + type ServiceRecipe, +} from './fiber'; +import type { InstantiationService } from './instantiationService'; +import type { ScopeKind } from './scope'; + +export function watchScopeUnits(container: InstantiationService, kind: ScopeKind): void { + if (container.cascadeDisposed) { + return; + } + const token = ScopeUnits(kind); + const host = container.fiberHost; + const view = host.collectionView(token); + const foldLedger = new Ledger(`scope-units:${kind}`); + container.anchorKernelEntry((reason) => foldLedger.teardown(reason), `scope-units:${kind}`); + + const materialized = new Map void | Promise>(); + + const materialize = (record: StoredRecord): void => { + const recipe = record.value as ServiceRecipe; + const name = record.providerName; + const unitLedger = new Ledger(`scope-units:${kind}:${name}`); + try { + if (isClassRecipe(recipe)) { + const instance = host.constructService(recipe, undefined) as Partial; + unitLedger.register(() => { + return instance.dispose?.(); + }, `unit:${name}`); + } else { + const facade = new FiberRuntime( + host, + unitLedger, + name, + undefined, + undefined, + new Set(recipe.inject ?? []), + undefined, + ); + const out = + typeof recipe === 'function' + ? recipe(facade, undefined) + : recipe.apply(facade, undefined); + unitLedger.effect((() => out) as EffectBody, `effect:${name}`); + } + } catch (error) { + void unitLedger.teardown('unload'); + onUnexpectedError(error); + return; + } + + let retracted = false; + let providerEntry: LedgerEntry | undefined; + const retract = (): void | Promise => { + if (retracted) { + return undefined; + } + retracted = true; + providerEntry?.release(); + providerEntry = undefined; + materialized.delete(record.id); + return unitLedger.teardown('unload'); + }; + if (!record.providerBook.isActive) { + void retract(); + return; + } + providerEntry = record.providerBook.register(() => { + void retract(); + }, `scope-units:${kind}`); + foldLedger.register(() => { + return retract(); + }, `record:${name}`); + materialized.set(record.id, retract); + }; + + const reconcile = (): void => { + if (!foldLedger.isActive) { + return; + } + const records = container.collectionStore.storedRecordsFor(token, container); + const seen = new Set(); + for (const record of records) { + seen.add(record.id); + if (!materialized.has(record.id)) { + materialize(record); + } + } + for (const [id, retract] of Array.from(materialized)) { + if (!seen.has(id)) { + void retract(); + } + } + }; + + const subscription = view.onDidChange(() => { + reconcile(); + }); + foldLedger.register(() => { + subscription.dispose(); + }, 'view-subscription'); + reconcile(); +} diff --git a/packages/agent-core-v2/src/_base/di/service.ts b/packages/agent-core-v2/src/_base/di/service.ts new file mode 100644 index 0000000000000000000000000000000000000000..66d61583ad15cd865d384eff2ed973b546d49b04 --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/service.ts @@ -0,0 +1,138 @@ +import type { Emitter } from '../event'; +import type { EffectBody } from '../lifecycle/disposer'; +import type { Ledger } from '../lifecycle/ledger'; +import type { CollectionToken } from './collection'; +import { + currentConstruction, + FiberProtocolError, + FiberState, + PendingFiberHandle, + SERVICE_MARK, + type BufferedOp, + type Fiber, + type FiberHandle, + type FiberProvideOptions, + type FiberRuntime, + type RecipeStatics, + type ServiceClassRecipe, + type ServiceRecipe, + type UnitInternals, +} from './fiber'; +import type { ServiceIdentifier, LiveRef } from './instantiation'; +import { Disposable } from './lifecycle'; + +export abstract class Service extends Disposable implements Fiber, UnitInternals { + private __unitBuffer: BufferedOp[] | null; + private __unitRuntime: FiberRuntime | undefined; + + readonly name: string; + readonly state: FiberState = FiberState.Active; + readonly config: unknown; + + constructor() { + super(); + const frame = currentConstruction(); + if ( + frame !== undefined && + frame.ctor === (new.target as unknown as new (...args: any[]) => any) + ) { + this.__unitBuffer = []; + this.config = frame.config; + } else { + this.__unitBuffer = null; + this.config = undefined; + } + this.name = (this.constructor as any).name || 'anonymous'; + } + + provide( + id: ServiceIdentifier, + recipe: ServiceClassRecipe, + opts?: FiberProvideOptions, + ): FiberHandle; + provide(id: ServiceIdentifier, instance: T): FiberHandle; + provide(recipe: ServiceRecipe, opts?: FiberProvideOptions): FiberHandle; + provide(token: CollectionToken, value: T): FiberHandle; + provide( + first: any, + second?: any, + third?: FiberProvideOptions, + ): FiberHandle { + if (this.__unitBuffer !== null) { + const pending = new PendingFiberHandle(this._pendingName(first)); + this.__unitBuffer.push((runtime) => { + pending.attach(runtime.provide(first, second, third)); + }); + return pending; + } + return this._runtime().provide(first, second, third); + } + + effect(body: EffectBody, label?: string): FiberHandle { + if (this.__unitBuffer !== null) { + const pending = new PendingFiberHandle(label ?? `effect:${this.name}`); + this.__unitBuffer.push((runtime) => { + pending.attach(runtime.effect(body, label)); + }); + return pending; + } + return this._runtime().effect(body, label); + } + + on(event: string | Emitter, handler: (e: any) => void): FiberHandle { + const label = typeof event === 'string' ? `on:${event}` : 'on:emitter'; + if (this.__unitBuffer !== null) { + const pending = new PendingFiberHandle(label); + this.__unitBuffer.push((runtime) => { + pending.attach(runtime.on(event, handler)); + }); + return pending; + } + return this._runtime().on(event, handler); + } + + get(id: ServiceIdentifier): T { + return this._runtime().get(id); + } + + ref(id: ServiceIdentifier): LiveRef { + return this._runtime().ref(id); + } + + get unitBook(): Ledger { + return this._store.ledger; + } + + takeUnitBuffer(): BufferedOp[] | null { + const buffer = this.__unitBuffer; + this.__unitBuffer = null; + return buffer; + } + + setUnitRuntime(runtime: FiberRuntime): void { + this.__unitRuntime = runtime; + } + + private _runtime(): FiberRuntime { + if (this.__unitRuntime === undefined) { + if (this.__unitBuffer !== null) { + throw new FiberProtocolError( + `unit '${this.name}': get/ref/provide reads are not available during construction — declare dependencies as constructor parameters (构造期只写不读)`, + ); + } + throw new FiberProtocolError( + `unit '${this.name}': no unit runtime is bound — a Service created by manual \`new\` has no capabilities; construct it through the container`, + ); + } + return this.__unitRuntime; + } + + private _pendingName(first: any): string { + if (typeof first === 'function') { + return (first as RecipeStatics).name ?? String(first); + } + return this.name; + } +} + +(Service.prototype as unknown as Record)[SERVICE_MARK] = true; diff --git a/packages/agent-core-v2/src/_base/di/serviceCollection.ts b/packages/agent-core-v2/src/_base/di/serviceCollection.ts new file mode 100644 index 0000000000000000000000000000000000000000..ba8550eb6ba71fdac2b44e1df0053cb5ee9a0a33 --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/serviceCollection.ts @@ -0,0 +1,144 @@ +import { Emitter } from '../event'; +import { SyncDescriptor } from './descriptors'; +import type { ServiceIdentifier } from './instantiation'; +import type { IDisposable } from './lifecycle'; + +export interface ServiceCollectionEntry { + readonly value: T | SyncDescriptor; + readonly uid: number; + readonly config?: unknown; + readonly recipe?: SyncDescriptor; +} + +export interface AvailabilityChange { + readonly oldUid: number | undefined; + readonly newUid: number | undefined; +} + +export class ServiceCollection { + private readonly _entries = new Map, ServiceCollectionEntry>(); + private readonly _emitters = new Map< + ServiceIdentifier, + Emitter + >(); + private _nextUid = 0; + + constructor( + ...entries: ReadonlyArray, unknown]> + ) { + for (const [id, value] of entries) { + this.set(id, value); + } + } + + set( + id: ServiceIdentifier, + instanceOrDescriptor: T | SyncDescriptor, + options?: { readonly config?: unknown }, + ): T | SyncDescriptor | undefined { + const prev = this._entries.get(id); + const uid = ++this._nextUid; + this._entries.set(id, { + value: instanceOrDescriptor, + uid, + config: options?.config, + }); + this._emitterFor(id).fire({ oldUid: prev?.uid, newUid: uid }); + return prev?.value as T | SyncDescriptor | undefined; + } + + materialize(id: ServiceIdentifier, instance: T): void { + const prev = this._entries.get(id); + if (prev === undefined || !(prev.value instanceof SyncDescriptor)) { + return; + } + this._entries.set(id, { + value: instance, + uid: prev.uid, + config: prev.config, + recipe: prev.value, + }); + } + + unmaterialize(id: ServiceIdentifier): void { + const prev = this._entries.get(id); + if (prev === undefined || prev.recipe === undefined) { + return; + } + this._entries.set(id, { + value: prev.recipe, + uid: prev.uid, + config: prev.config, + }); + } + + setConfig(id: ServiceIdentifier, config: unknown): void { + const prev = this._entries.get(id); + if (prev === undefined) { + return; + } + this._entries.set(id, { ...prev, config }); + } + + delete(id: ServiceIdentifier): T | SyncDescriptor | undefined { + const prev = this._entries.get(id); + if (prev === undefined) { + return undefined; + } + this._entries.delete(id); + this._emitterFor(id).fire({ oldUid: prev.uid, newUid: undefined }); + return prev.value as T | SyncDescriptor | undefined; + } + + entry(id: ServiceIdentifier): ServiceCollectionEntry | undefined { + return this._entries.get(id); + } + + uidOf(id: ServiceIdentifier): number | undefined { + return this._entries.get(id)?.uid; + } + + configOf(id: ServiceIdentifier): unknown { + return this._entries.get(id)?.config; + } + + onDidChange( + id: ServiceIdentifier, + listener: (change: AvailabilityChange) => void, + ): IDisposable { + return this._emitterFor(id).event(listener); + } + + has(id: ServiceIdentifier): boolean { + return this._entries.has(id); + } + + get(id: ServiceIdentifier): T | SyncDescriptor | undefined { + return this._entries.get(id)?.value as T | SyncDescriptor | undefined; + } + + forEach( + callback: ( + id: ServiceIdentifier, + value: unknown, + ) => void, + ): void { + this._entries.forEach((entry, id) => { callback(id, entry.value); }); + } + + dispose(): void { + for (const emitter of this._emitters.values()) { + emitter.dispose(); + } + this._emitters.clear(); + } + + private _emitterFor(id: ServiceIdentifier): Emitter { + let emitter = this._emitters.get(id); + if (emitter === undefined) { + emitter = new Emitter(); + this._emitters.set(id, emitter); + } + return emitter; + } +} diff --git a/packages/agent-core-v2/src/_base/di/test.ts b/packages/agent-core-v2/src/_base/di/test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b41a4e68fe781033399e7392d23ecba03999dfc --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/test.ts @@ -0,0 +1,53 @@ +export { + createServices, + TestInstantiationService, +} from './testInstantiationService'; +export type { + CreateServicesOptions, + ServiceGroup, + ServiceRegistration, +} from './testInstantiationService'; + +import { type ServiceIdentifier } from './instantiation'; +import { createAppScope, createScopedChildHandle, Scope, type ScopeKind, type ScopeSeed } from './scope'; + +export interface ScopedTestHost { + readonly app: Scope; + child(kind: ScopeKind, id: string, stubs?: ScopeSeed): Scope; + childOf(parent: Scope, kind: ScopeKind, id: string, stubs?: ScopeSeed): Scope; + dispose(): void; +} + +export function createScopedTestHost(appStubs: ScopeSeed = []): ScopedTestHost { + const app = createAppScope({ seeds: appStubs }); + return { + app, + child(kind, id, stubs = []) { + if (kind === 'program') { + const handle = createScopedChildHandle(app.instantiation, kind, id, { seeds: stubs }); + return { + id: handle.id, + kind: handle.kind, + accessor: handle.accessor, + dispose: () => { + void handle.dispose(); + }, + } as Scope; + } + return app.createChild(kind, id, { seeds: stubs }); + }, + childOf(parent, kind, id, stubs = []) { + return parent.createChild(kind, id, { seeds: stubs }); + }, + dispose() { + app.dispose(); + }, + }; +} + +export function stubPair( + id: ServiceIdentifier, + instance: T, +): readonly [ServiceIdentifier, T] { + return [id, instance]; +} diff --git a/packages/agent-core-v2/src/_base/di/testInstantiationService.ts b/packages/agent-core-v2/src/_base/di/testInstantiationService.ts new file mode 100644 index 0000000000000000000000000000000000000000..aaa07a17588161f3c53246ad46e9bbf113048760 --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/testInstantiationService.ts @@ -0,0 +1,348 @@ +import * as sinon from 'sinon'; + +import { SyncDescriptor, type SyncDescriptor0 } from './descriptors'; +import { + type GetLeadingNonServiceArgs, + type ServiceIdentifier, + type ServicesAccessor, +} from './instantiation'; +import { InstantiationService, Trace } from './instantiationService'; +import { DisposableStore, dispose, isDisposable, toDisposable, type IDisposable } from './lifecycle'; +import { ServiceCollection } from './serviceCollection'; + +type AnyConstructor = new (...args: any[]) => T; + +interface IServiceMock { + id: ServiceIdentifier; + service?: any; +} + +const isSinonSpyLike = (fn: Function): fn is sinon.SinonSpy => + fn && 'callCount' in fn; + +export class TestInstantiationService extends InstantiationService implements IDisposable, ServicesAccessor { + private readonly _classStubs = new Map(); + private readonly _parentTestService?: TestInstantiationService; + + constructor( + private readonly _serviceCollection: ServiceCollection = new ServiceCollection(), + strict: boolean = false, + parent?: InstantiationService, + private readonly _properDispose: boolean = true, + ) { + super(_serviceCollection, strict, parent); + if (parent instanceof TestInstantiationService) { + this._parentTestService = parent; + } + } + + public get(id: ServiceIdentifier): T { + return super._getOrCreateServiceInstance( + id, + Trace.traceCreation(false, TestInstantiationService), + ); + } + + public set( + id: ServiceIdentifier, + instanceOrDescriptor: T | SyncDescriptor, + ): T | SyncDescriptor | undefined { + const prev = this._serviceCollection.get(id); + this.provide(id, instanceOrDescriptor, { activation: 'ondemand' }); + return prev; + } + + public mock(id: ServiceIdentifier): T | sinon.SinonMock { + return this._create({ id }, { mock: true }); + } + + public stubInstance(ctor: AnyConstructor, instance: Partial): void { + this._classStubs.set(ctor, instance); + } + + protected _getClassStub(ctor: Function): unknown { + return this._classStubs.get(ctor) ?? this._parentTestService?._getClassStub(ctor); + } + + public override createInstance(descriptor: SyncDescriptor0): T; + public override createInstance< + Ctor extends AnyConstructor, + R extends InstanceType, + >( + ctor: Ctor, + ...args: GetLeadingNonServiceArgs> + ): R; + public override createInstance( + ctorOrDescriptor: any, + ...rest: unknown[] + ): unknown { + const stub = + ctorOrDescriptor instanceof SyncDescriptor + ? this._getClassStub(ctorOrDescriptor.ctor) + : this._getClassStub(ctorOrDescriptor); + + if (stub !== undefined) { + return stub; + } + + if (ctorOrDescriptor instanceof SyncDescriptor) { + return super.createInstance(ctorOrDescriptor, ...rest); + } + return super.createInstance(ctorOrDescriptor, ...rest); + } + + public stub( + id: ServiceIdentifier, + instanceOrDescriptor: Partial> | SyncDescriptor, + ): T | SyncDescriptor; + public stub(id: ServiceIdentifier, ctor: AnyConstructor): T; + public stub( + id: ServiceIdentifier, + obj: Partial> | Function, + property: string, + value: V, + ): V extends Function ? sinon.SinonSpy : sinon.SinonStub; + public stub( + id: ServiceIdentifier, + property: string, + value: V, + ): V extends Function ? sinon.SinonSpy : sinon.SinonStub; + public stub( + id: ServiceIdentifier, + arg2: any, + arg3?: string, + arg4?: any, + ): T | SyncDescriptor | sinon.SinonStub | sinon.SinonSpy { + if (arg2 instanceof SyncDescriptor && typeof arg3 !== 'string') { + this._serviceCollection.set(id, arg2); + return arg2; + } + + if (typeof arg2 !== 'string' && typeof arg3 !== 'string') { + const service = this._create(arg2, { stub: true }) as T; + this._serviceCollection.set(id, service); + return service; + } + + const service = typeof arg2 !== 'string' ? arg2 : undefined; + const property = typeof arg2 === 'string' ? arg2 : arg3; + const value = typeof arg2 === 'string' ? arg3 : arg4; + + if (typeof property !== 'string') { + throw new TypeError('stub requires a method/property name'); + } + + const serviceMock: IServiceMock = { id, service }; + const stubObject = this._create(serviceMock, { stub: true }, Boolean(service && !property)) as Record; + const replacement = this._createReplacement(value); + + const current = stubObject[property] as { restore?: () => void } | undefined; + if (current && typeof current.restore === 'function') { + current.restore(); + } + stubObject[property] = replacement; + return replacement; + } + + public stubPromise( + id?: ServiceIdentifier, + fnProperty?: string, + value?: any, + ): T | sinon.SinonStub; + public stubPromise( + id?: ServiceIdentifier, + ctor?: any, + fnProperty?: string, + value?: V, + ): V extends Function ? sinon.SinonSpy : sinon.SinonStub; + public stubPromise( + id?: ServiceIdentifier, + obj?: any, + fnProperty?: string, + value?: V, + ): V extends Function ? sinon.SinonSpy : sinon.SinonStub; + public stubPromise( + arg1?: any, + arg2?: any, + arg3?: any, + arg4?: any, + ): unknown { + arg3 = typeof arg2 === 'string' ? Promise.resolve(arg3) : arg3; + arg4 = typeof arg2 !== 'string' && typeof arg3 === 'string' ? Promise.resolve(arg4) : arg4; + return this.stub(arg1, arg2, arg3, arg4); + } + + public spy(id: ServiceIdentifier, property: string): sinon.SinonSpy { + const spy = sinon.spy(); + this.stub(id, property, spy); + return spy; + } + + private _create(serviceMock: IServiceMock, options: SinonOptions, reset?: boolean): T; + private _create(ctor: any, options: SinonOptions): T | sinon.SinonMock; + private _create(arg1: any, options: SinonOptions, reset: boolean = false): any { + if (this._isServiceMock(arg1)) { + const service = this._getOrCreateService(arg1, options, reset); + if (options.mock) { + return sinon.mock(service); + } + this._serviceCollection.set(arg1.id, service); + return service; + } + return options.mock ? sinon.mock(arg1) : this._createStub(arg1); + } + + private _getOrCreateService( + serviceMock: IServiceMock, + opts: SinonOptions, + reset?: boolean, + ): T { + const service = this._serviceCollection.get(serviceMock.id); + if (!reset && service && !(service instanceof SyncDescriptor)) { + if (opts.stub && this._hasSinonOption(service, 'stub')) { + return service as T; + } + if (opts.mock && this._hasSinonOption(service, 'mock')) { + return service as T; + } + return service as T; + } + return this._createService(serviceMock, opts); + } + + private _createService(serviceMock: IServiceMock, opts: SinonOptions): T { + const existing = this._serviceCollection.get(serviceMock.id); + const source = + serviceMock.service + ?? (existing instanceof SyncDescriptor ? existing.ctor : undefined); + const service = this._createStub(source); + service.sinonOptions = opts; + return service as T; + } + + private _createStub(arg: any): any { + if (arg instanceof SyncDescriptor) { + return sinon.createStubInstance(arg.ctor); + } + if (typeof arg === 'function') { + return sinon.createStubInstance(arg); + } + if (arg && typeof arg === 'object') { + return arg; + } + return Object.create(null); + } + + private _createReplacement(value: any): sinon.SinonStub | sinon.SinonSpy { + if (typeof value === 'function') { + return isSinonSpyLike(value) ? value : sinon.spy(value); + } + return value ? sinon.stub().returns(value) : sinon.stub(); + } + + private _hasSinonOption(service: any, key: keyof SinonOptions): boolean { + return Boolean(service?.sinonOptions?.[key]); + } + + private _isServiceMock(arg: any): arg is IServiceMock { + return typeof arg === 'object' && arg !== null && 'id' in arg; + } + + public override createChild(services: ServiceCollection): TestInstantiationService { + return super.createChild(services) as TestInstantiationService; + } + + protected override _createChildService(services: ServiceCollection): InstantiationService { + return new TestInstantiationService(services, false, this); + } + + public override dispose(): void { + sinon.restore(); + if (this._properDispose) { + super.dispose(); + } + } + + public override disposeAsync(): Promise { + sinon.restore(); + if (this._properDispose) { + return super.disposeAsync(); + } + return Promise.resolve(); + } +} + +interface SinonOptions { + mock?: boolean; + stub?: boolean; +} + +export interface ServiceRegistration { + define(id: ServiceIdentifier, ctor: new (...args: any[]) => T): void; + defineInstance(id: ServiceIdentifier, instance: T): void; + definePartialInstance(id: ServiceIdentifier, instance: Partial): void; +} + +export type ServiceGroup = (reg: ServiceRegistration) => void; + +export interface CreateServicesOptions { + readonly base?: readonly ServiceGroup[]; + readonly additionalServices?: (reg: ServiceRegistration) => void; + readonly strict?: boolean; +} + +export function createServices( + disposables: DisposableStore, + options: CreateServicesOptions = {}, +): TestInstantiationService { + const serviceCollection = new ServiceCollection(); + const instanceIds = new Set>(); + + const register = ( + id: ServiceIdentifier, + value: T | Partial | SyncDescriptor, + isInstance: boolean, + overwrite: boolean, + ): void => { + if (overwrite || !serviceCollection.has(id)) { + serviceCollection.set(id, value as T | SyncDescriptor); + } + if (isInstance) { + instanceIds.add(id); + } + }; + + const baseReg: ServiceRegistration = { + define: (id, ctor) => register(id, new SyncDescriptor(ctor), false, false), + defineInstance: (id, instance) => register(id, instance, true, false), + definePartialInstance: (id, instance) => register(id, instance, true, false), + }; + + for (const group of options.base ?? []) { + group(baseReg); + } + + if (options.additionalServices) { + const overrideReg: ServiceRegistration = { + define: (id, ctor) => register(id, new SyncDescriptor(ctor), false, true), + defineInstance: (id, instance) => register(id, instance, true, true), + definePartialInstance: (id, instance) => register(id, instance, true, true), + }; + options.additionalServices(overrideReg); + } + + const instantiationService = disposables.add( + new TestInstantiationService(serviceCollection, options.strict ?? false), + ); + disposables.add(toDisposable(() => { + const serviceDisposables: IDisposable[] = []; + for (const id of instanceIds) { + const instance = serviceCollection.get(id); + if (isDisposable(instance)) { + serviceDisposables.push(instance); + } + } + dispose(serviceDisposables); + })); + return instantiationService; +} diff --git a/packages/agent-core-v2/src/_base/di/util/linkedList.ts b/packages/agent-core-v2/src/_base/di/util/linkedList.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ae8b60a6953d3e258c33ef00b2d8fdf35c62a4e --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/util/linkedList.ts @@ -0,0 +1,84 @@ +class Node { + static readonly Undefined = new Node(undefined); + + element: E; + next: Node | typeof Node.Undefined; + prev: Node | typeof Node.Undefined; + + constructor(element: E) { + this.element = element; + this.next = Node.Undefined; + this.prev = Node.Undefined; + } +} + +export class LinkedList { + private _first: Node | typeof Node.Undefined = Node.Undefined; + private _last: Node | typeof Node.Undefined = Node.Undefined; + private _size: number = 0; + + get size(): number { + return this._size; + } + + isEmpty(): boolean { + return this._first === Node.Undefined; + } + + push(element: E): () => void { + const newNode = new Node(element); + if (this._first === Node.Undefined) { + this._first = newNode; + this._last = newNode; + } else { + const oldLast = this._last as Node; + this._last = newNode; + newNode.prev = oldLast; + oldLast.next = newNode; + } + this._size += 1; + + let didRemove = false; + return () => { + if (!didRemove) { + didRemove = true; + this._remove(newNode); + } + }; + } + + shift(): E | undefined { + if (this._first === Node.Undefined) { + return undefined; + } + const node = this._first as Node; + this._remove(node); + return node.element; + } + + private _remove(node: Node): void { + if (node.prev !== Node.Undefined && node.next !== Node.Undefined) { + const anchor = node.prev as Node; + anchor.next = node.next; + (node.next as Node).prev = anchor; + } else if (node.prev === Node.Undefined && node.next === Node.Undefined) { + this._first = Node.Undefined; + this._last = Node.Undefined; + } else if (node.next === Node.Undefined) { + this._last = (this._last as Node).prev!; + (this._last as Node).next = Node.Undefined; + } else if (node.prev === Node.Undefined) { + this._first = (this._first as Node).next!; + (this._first as Node).prev = Node.Undefined; + } + this._size -= 1; + } + + *[Symbol.iterator](): Iterator { + let node = this._first; + while (node !== Node.Undefined) { + yield (node as Node).element; + node = (node as Node).next; + } + } +} diff --git a/packages/agent-core-v2/src/_base/errors/codes.ts b/packages/agent-core-v2/src/_base/errors/codes.ts new file mode 100644 index 0000000000000000000000000000000000000000..c10012fad16135c07362ccdcd0af4532dfc3fdf9 --- /dev/null +++ b/packages/agent-core-v2/src/_base/errors/codes.ts @@ -0,0 +1,70 @@ +export interface ErrorInfo { + readonly title: string; + readonly retryable: boolean; + readonly public: boolean; + readonly action?: string; +} + +export interface ErrorDomain { + readonly codes: { readonly [name: string]: string }; + readonly retryable?: ReadonlyArray; + readonly info?: { readonly [code: string]: ErrorInfo }; +} + +const registeredCodes = new Map(); +const retryableCodes = new Set(); +const infoOverrides: { [code: string]: ErrorInfo } = {}; + +export function registerErrorDomain(domain: ErrorDomain): void { + for (const code of Object.values(domain.codes)) { + const owner = registeredCodes.get(code); + if (owner !== undefined && owner !== domain.codes) { + throw new Error(`error code '${code}' is registered by two different domains`); + } + registeredCodes.set(code, domain.codes); + } + for (const code of domain.retryable ?? []) { + retryableCodes.add(code); + } + for (const [code, info] of Object.entries(domain.info ?? {})) { + infoOverrides[code] = info; + } +} + +export function isErrorCode(code: unknown): code is string { + return typeof code === 'string' && registeredCodes.has(code); +} + +export function errorInfo(code: string): ErrorInfo { + const override = infoOverrides[code]; + if (override !== undefined) return override; + return { + title: code, + retryable: retryableCodes.has(code), + public: true, + }; +} + +export const CoreErrors = { + codes: { + INTERNAL: 'internal', + NOT_IMPLEMENTED: 'not_implemented', + VALIDATION_FAILED: 'validation.failed', + }, + info: { + internal: { + title: 'Internal error', + retryable: false, + public: true, + action: 'Inspect logs or report the issue with diagnostics.', + }, + not_implemented: { + title: 'Not implemented', + retryable: false, + public: true, + action: 'This feature is not implemented yet.', + }, + }, +} as const satisfies ErrorDomain; + +registerErrorDomain(CoreErrors); diff --git a/packages/agent-core-v2/src/_base/errors/errorMessage.ts b/packages/agent-core-v2/src/_base/errors/errorMessage.ts new file mode 100644 index 0000000000000000000000000000000000000000..d30f2ea6baa1f6d2a287cbdff6bf2df434fe7d59 --- /dev/null +++ b/packages/agent-core-v2/src/_base/errors/errorMessage.ts @@ -0,0 +1,23 @@ +import { isCodedError } from './serialize'; + +export function toErrorMessage(error: unknown, verbose = false): string { + if (isCodedError(error)) { + const base = `[${error.code}] ${error.message}`; + return verbose && error.details ? `${base} ${JSON.stringify(error.details)}` : base; + } + if (error instanceof Error) { + const base = error.message || error.name; + if (verbose && error.cause !== undefined) { + return `${base} (caused by: ${toErrorMessage(error.cause)})`; + } + return base; + } + if (typeof error === 'string') { + return error; + } + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} diff --git a/packages/agent-core-v2/src/_base/errors/errors.ts b/packages/agent-core-v2/src/_base/errors/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..88b1ce272edc0bfb52c5d5e94e276db68dfbb3ee --- /dev/null +++ b/packages/agent-core-v2/src/_base/errors/errors.ts @@ -0,0 +1,70 @@ +import { CoreErrors } from './codes'; +import type { ErrorCode } from '#/errors'; + +export class ExpectedError extends Error { + readonly isExpected = true; +} + +export class ErrorNoTelemetry extends Error { + constructor(message?: string) { + super(message); + this.name = 'CodeExpectedError'; + } + + static fromError(error: Error): ErrorNoTelemetry { + const wrapped = new ErrorNoTelemetry(error.message); + wrapped.stack = error.stack; + return wrapped; + } + + static isErrorNoTelemetry(error: unknown): error is ErrorNoTelemetry { + return error instanceof Error && error.name === 'CodeExpectedError'; + } +} + +export class BugIndicatingError extends Error { + constructor(message?: string) { + super(message ?? 'An unexpected bug occurred.'); + this.name = 'BugIndicatingError'; + } +} + +export interface Error2Options { + readonly details?: Readonly>; + readonly cause?: unknown; + readonly name?: string; +} + +export class Error2 extends Error { + readonly code: ErrorCode; + readonly details?: Readonly>; + + constructor(code: ErrorCode, message: string, options?: Error2Options) { + super(message, options?.cause === undefined ? undefined : { cause: options.cause }); + this.name = options?.name ?? 'Error2'; + this.code = code; + this.details = options?.details; + } +} + +export function isError2(error: unknown): error is Error2 { + return error instanceof Error2; +} + +export function unwrapErrorCause(error: unknown): unknown { + let current = error; + while (current instanceof Error2 && current.cause !== undefined) { + current = current.cause; + } + return current; +} + +export class NotImplementedError extends Error2 { + constructor(feature?: string) { + super( + CoreErrors.codes.NOT_IMPLEMENTED, + feature ? `Not implemented: ${feature}` : 'Not implemented', + ); + this.name = 'NotImplementedError'; + } +} diff --git a/packages/agent-core-v2/src/_base/errors/serialize.ts b/packages/agent-core-v2/src/_base/errors/serialize.ts new file mode 100644 index 0000000000000000000000000000000000000000..2176204e17fc2b484b5fd080d17939e81a61d5a1 --- /dev/null +++ b/packages/agent-core-v2/src/_base/errors/serialize.ts @@ -0,0 +1,97 @@ +import { CoreErrors, errorInfo, isErrorCode } from './codes'; +import type { ErrorCode } from '#/errors'; +import { Error2 } from './errors'; + +export interface ErrorPayload { + readonly code: ErrorCode; + readonly message: string; + readonly name?: string; + readonly details?: Readonly>; + readonly retryable: boolean; + readonly cause?: ErrorPayload; +} + +export type KimiErrorPayload = ErrorPayload; + +export interface CodedErrorShape { + readonly code: ErrorCode; + readonly message: string; + readonly name?: string; + readonly details?: Readonly>; +} + +const MAX_CAUSE_DEPTH = 8; + +export function isCodedError(error: unknown): error is CodedErrorShape { + if (error === null || typeof error !== 'object') { + return false; + } + const code = (error as { readonly code?: unknown }).code; + return isErrorCode(code); +} + +export function makeErrorPayload( + code: ErrorCode, + message: string, + options?: { + readonly details?: Readonly>; + readonly name?: string; + }, +): ErrorPayload { + return { + code, + message, + name: options?.name, + details: options?.details, + retryable: errorInfo(code).retryable, + }; +} + +export function toErrorPayload(error: unknown): ErrorPayload { + return toErrorPayloadAtDepth(error, 0); +} + +function toErrorPayloadAtDepth(error: unknown, depth: number): ErrorPayload { + const payload = toShallowErrorPayload(error); + if (depth >= MAX_CAUSE_DEPTH) { + return payload; + } + const cause = readErrorCause(error); + if (cause === undefined) { + return payload; + } + return { ...payload, cause: toErrorPayloadAtDepth(cause, depth + 1) }; +} + +function toShallowErrorPayload(error: unknown): ErrorPayload { + if (isCodedError(error)) { + return { + code: error.code, + message: error.message, + name: error.name, + details: error.details, + retryable: errorInfo(error.code).retryable, + }; + } + if (error instanceof Error) { + return makeErrorPayload(CoreErrors.codes.INTERNAL, error.message, { name: error.name }); + } + return makeErrorPayload(CoreErrors.codes.INTERNAL, String(error)); +} + +function readErrorCause(error: unknown): unknown { + if (error === null || typeof error !== 'object') { + return undefined; + } + return (error as { readonly cause?: unknown }).cause; +} + +export const toKimiErrorPayload = toErrorPayload; + +export function fromErrorPayload(payload: ErrorPayload): Error2 { + return new Error2(payload.code, payload.message, { + name: payload.name, + details: payload.details, + cause: payload.cause === undefined ? undefined : fromErrorPayload(payload.cause), + }); +} diff --git a/packages/agent-core-v2/src/_base/errors/unexpectedError.ts b/packages/agent-core-v2/src/_base/errors/unexpectedError.ts new file mode 100644 index 0000000000000000000000000000000000000000..3d1dcdedb081f653ba238e376c567582e5da6830 --- /dev/null +++ b/packages/agent-core-v2/src/_base/errors/unexpectedError.ts @@ -0,0 +1,31 @@ +export type UnexpectedErrorHandler = (err: unknown) => void; + +const defaultHandler: UnexpectedErrorHandler = (err) => { + console.error('[unexpected]', err); +}; + +let currentHandler: UnexpectedErrorHandler = defaultHandler; + +export function setUnexpectedErrorHandler(handler: UnexpectedErrorHandler): void { + currentHandler = handler; +} + +export function resetUnexpectedErrorHandler(): void { + currentHandler = defaultHandler; +} + +export function onUnexpectedError(err: unknown): void { + try { + currentHandler(err); + } catch (handlerErr) { + console.error('[unexpected] handler threw', handlerErr, 'while reporting', err); + } +} + +export function safelyCallListener(listener: () => void): void { + try { + listener(); + } catch (err) { + onUnexpectedError(err); + } +} diff --git a/packages/agent-core-v2/src/_base/execEnv/bufferedReadable.ts b/packages/agent-core-v2/src/_base/execEnv/bufferedReadable.ts new file mode 100644 index 0000000000000000000000000000000000000000..00ed8decea6c7f3753ea0ce68216c7e167a87dc1 --- /dev/null +++ b/packages/agent-core-v2/src/_base/execEnv/bufferedReadable.ts @@ -0,0 +1,52 @@ +import { Readable } from 'node:stream'; + +export class BufferedReadable extends Readable { + private readonly _source: Readable; + private _ended: boolean = false; + + constructor(source: Readable) { + super({ highWaterMark: 128 * 1024 }); + this._source = source; + this._source.on('data', this._onData); + this._source.on('end', this._onEnd); + this._source.on('close', this._onClose); + this._source.on('error', this._onError); + } + + override _read(): void { + if (!this._ended && !this.destroyed) { + this._source.resume(); + } + } + + override _destroy(error: Error | null, callback: (error?: Error | null) => void): void { + this._source.off('data', this._onData); + this._source.off('end', this._onEnd); + this._source.off('close', this._onClose); + this._source.off('error', this._onError); + this._source.destroy(); + callback(error); + } + + private readonly _onData = (chunk: string | Uint8Array): void => { + if (!this.push(chunk)) { + this._source.pause(); + } + }; + + private readonly _onEnd = (): void => { + this._ended = true; + this.push(null); + }; + + private readonly _onClose = (): void => { + if (!this._ended) { + this._ended = true; + this.push(null); + } + }; + + private readonly _onError = (error: Error): void => { + this.destroy(error); + }; +} diff --git a/packages/agent-core-v2/src/_base/execEnv/decodeText.ts b/packages/agent-core-v2/src/_base/execEnv/decodeText.ts new file mode 100644 index 0000000000000000000000000000000000000000..62bae9b58d19eb5c17497f0f00ca64a222a874da --- /dev/null +++ b/packages/agent-core-v2/src/_base/execEnv/decodeText.ts @@ -0,0 +1,185 @@ +export type TextDecodeErrors = 'strict' | 'replace' | 'ignore'; + +export async function* readUtf8Lines( + source: AsyncIterable, + errors: TextDecodeErrors = 'strict', +): AsyncGenerator { + let pending: Buffer[] = []; + let offset = 0; + let pendingOffset = 0; + for await (const bytes of source) { + const chunk = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let start = 0; + for (let i = 0; i < chunk.length; i++) { + if (chunk[i] !== 0x0a) continue; + const piece = chunk.subarray(start, i + 1); + const lineOffset = pending.length === 0 ? offset + start : pendingOffset; + const line = pending.length === 0 ? piece : Buffer.concat([...pending, piece]); + yield decodeTextWithErrors(line, 'utf-8', errors, lineOffset !== 0); + pending = []; + start = i + 1; + } + if (start < chunk.length) { + if (pending.length === 0) pendingOffset = offset + start; + pending.push(Buffer.from(chunk.subarray(start))); + } + offset += chunk.length; + } + if (pending.length > 0) yield decodeTextWithErrors(Buffer.concat(pending), 'utf-8', errors, pendingOffset !== 0); +} + +function isUtf8Continuation(byte: number): boolean { + return byte >= 0x80 && byte <= 0xbf; +} + +function decodeUtf8Ignore(data: Buffer): string { + let output = ''; + let i = 0; + + while (i < data.length) { + const b0 = data[i]; + if (b0 === undefined) break; + + if (b0 <= 0x7f) { + output += String.fromCodePoint(b0); + i += 1; + continue; + } + + if (b0 >= 0xc2 && b0 <= 0xdf) { + const b1 = data[i + 1]; + if (b1 !== undefined && isUtf8Continuation(b1)) { + output += String.fromCodePoint(((b0 & 0x1f) << 6) | (b1 & 0x3f)); + i += 2; + continue; + } + i += 1; + continue; + } + + if (b0 >= 0xe0 && b0 <= 0xef) { + const b1 = data[i + 1]; + const b2 = data[i + 2]; + const validSecond = + b1 !== undefined && + ((b0 === 0xe0 && b1 >= 0xa0 && b1 <= 0xbf) || + (b0 >= 0xe1 && b0 <= 0xec && isUtf8Continuation(b1)) || + (b0 === 0xed && b1 >= 0x80 && b1 <= 0x9f) || + (b0 >= 0xee && b0 <= 0xef && isUtf8Continuation(b1))); + + if (validSecond && b2 !== undefined && isUtf8Continuation(b2)) { + output += String.fromCodePoint(((b0 & 0x0f) << 12) | ((b1 & 0x3f) << 6) | (b2 & 0x3f)); + i += 3; + continue; + } + i += 1; + continue; + } + + if (b0 >= 0xf0 && b0 <= 0xf4) { + const b1 = data[i + 1]; + const b2 = data[i + 2]; + const b3 = data[i + 3]; + const validSecond = + b1 !== undefined && + ((b0 === 0xf0 && b1 >= 0x90 && b1 <= 0xbf) || + (b0 >= 0xf1 && b0 <= 0xf3 && isUtf8Continuation(b1)) || + (b0 === 0xf4 && b1 >= 0x80 && b1 <= 0x8f)); + + if ( + validSecond && + b2 !== undefined && + b3 !== undefined && + isUtf8Continuation(b2) && + isUtf8Continuation(b3) + ) { + output += String.fromCodePoint( + ((b0 & 0x07) << 18) | ((b1 & 0x3f) << 12) | ((b2 & 0x3f) << 6) | (b3 & 0x3f), + ); + i += 4; + continue; + } + i += 1; + continue; + } + + i += 1; + } + + return output; +} + +function decodeUtf16LeIgnore(data: Buffer): string { + let output = ''; + let i = 0; + + while (i + 1 < data.length) { + const first = data[i]; + const second = data[i + 1]; + if (first === undefined || second === undefined) break; + + const codeUnit = first | (second << 8); + + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const lowFirst = data[i + 2]; + const lowSecond = data[i + 3]; + if (lowFirst !== undefined && lowSecond !== undefined) { + const low = lowFirst | (lowSecond << 8); + if (low >= 0xdc00 && low <= 0xdfff) { + const codePoint = 0x10000 + ((codeUnit - 0xd800) << 10) + (low - 0xdc00); + output += String.fromCodePoint(codePoint); + i += 4; + continue; + } + } + i += 2; + continue; + } + + if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + i += 2; + continue; + } + + output += String.fromCodePoint(codeUnit); + i += 2; + } + + return output; +} + +export function decodeTextWithErrors( + data: Buffer, + encoding: BufferEncoding, + errors: TextDecodeErrors = 'strict', + ignoreBOM: boolean = false, +): string { + let webLabel: string | undefined; + switch (encoding) { + case 'utf-8': + case 'utf8': + webLabel = 'utf-8'; + break; + case 'utf16le': + case 'ucs2': + case 'ucs-2': + webLabel = 'utf-16le'; + break; + default: + webLabel = undefined; + } + + if (webLabel === undefined) { + return data.toString(encoding); + } + + if (errors === 'strict') { + return new TextDecoder(webLabel, { fatal: true, ignoreBOM }).decode(data); + } + + if (errors === 'ignore') { + return webLabel === 'utf-8' ? decodeUtf8Ignore(data) : decodeUtf16LeIgnore(data); + } + + return new TextDecoder(webLabel, { fatal: false, ignoreBOM }).decode(data); +} diff --git a/packages/agent-core-v2/src/_base/execEnv/environmentProbe.ts b/packages/agent-core-v2/src/_base/execEnv/environmentProbe.ts new file mode 100644 index 0000000000000000000000000000000000000000..6f86185516b9621bbcd454bcf90ee85c0a94c53c --- /dev/null +++ b/packages/agent-core-v2/src/_base/execEnv/environmentProbe.ts @@ -0,0 +1,322 @@ +import { execFile as nodeExecFile } from 'node:child_process'; +import { constants as fsConstants } from 'node:fs'; +import { access } from 'node:fs/promises'; +import * as nodeOs from 'node:os'; +import * as nodePath from 'node:path'; + +export type OsKind = string; +export type ShellName = 'bash' | 'sh'; +export type PathClass = 'posix' | 'win32'; + +export class ProbeShellNotFoundError extends Error { + readonly checked: readonly string[]; + + constructor(message: string, checked: readonly string[]) { + super(message); + this.name = 'ProbeShellNotFoundError'; + this.checked = checked; + } +} + +export interface HostEnvironmentInfo { + readonly osKind: OsKind; + readonly osArch: string; + readonly osVersion: string; + readonly shellName: ShellName; + readonly shellPath: string; + readonly pathClass: PathClass; + readonly homeDir: string; +} + +export interface HostEnvironmentProbeDeps { + readonly platform: string; + readonly arch: string; + readonly release: string; + readonly homeDir: string; + readonly env: Record; + readonly isFile: (path: string) => Promise; + readonly execFileText: ( + file: string, + args: readonly string[], + timeoutMs: number, + ) => Promise; +} + +const GIT_EXEC_PATH_TIMEOUT_MS = 5_000; + +const MINGW_PREFIX_SET: ReadonlySet = new Set([ + 'mingw32', + 'mingw64', + 'ucrt64', + 'clang64', + 'clangarm64', +]); + +function resolveOsKind(platform: string): OsKind { + switch (platform) { + case 'darwin': + return 'macOS'; + case 'linux': + return 'Linux'; + case 'win32': + return 'Windows'; + default: + return platform; + } +} + +export async function probeHostEnvironment( + deps: HostEnvironmentProbeDeps, +): Promise { + const osKind = resolveOsKind(deps.platform); + const osArch = deps.arch; + const osVersion = deps.release; + const pathClass: PathClass = deps.platform === 'win32' ? 'win32' : 'posix'; + + if (deps.platform === 'win32') { + const shellPath = await locateWindowsGitBash(deps); + return { + osKind, + osArch, + osVersion, + shellName: 'bash', + shellPath, + pathClass, + homeDir: deps.homeDir, + }; + } + + const candidates: readonly string[] = ['/bin/bash', '/usr/bin/bash', '/usr/local/bin/bash']; + let found: string | undefined; + for (const p of candidates) { + if (await deps.isFile(p)) { + found = p; + break; + } + } + if (found !== undefined) { + return { + osKind, + osArch, + osVersion, + shellName: 'bash', + shellPath: found, + pathClass, + homeDir: deps.homeDir, + }; + } + return { + osKind, + osArch, + osVersion, + shellName: 'sh', + shellPath: '/bin/sh', + pathClass, + homeDir: deps.homeDir, + }; +} + +async function locateWindowsGitBash(deps: HostEnvironmentProbeDeps): Promise { + const checked: string[] = []; + + const override = deps.env['KIMI_SHELL_PATH']?.trim(); + if (override !== undefined && override.length > 0) { + checked.push(override); + if (await deps.isFile(override)) { + return override; + } + } + + const gitExecutables = await findExecutablesOnPath( + 'git.exe', + deps.env['PATH'], + deps.platform, + deps.isFile, + ); + + for (const gitExe of gitExecutables) { + const inferred = gitBashCandidatesFromGitExe(gitExe); + if (inferred !== undefined) { + for (const candidate of inferred) { + checked.push(candidate); + if (await deps.isFile(candidate)) { + return candidate; + } + } + } + + const gitExecPath = await readGitExecPath(deps, gitExe); + if (gitExecPath === undefined) { + continue; + } + for (const candidate of gitBashCandidatesFromGitExecPath(gitExecPath)) { + checked.push(candidate); + if (await deps.isFile(candidate)) { + return candidate; + } + } + } + + const candidates: string[] = [ + 'C:\\Program Files\\Git\\bin\\bash.exe', + 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', + 'C:\\Program Files (x86)\\Git\\bin\\bash.exe', + 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', + ]; + const localAppData = deps.env['LOCALAPPDATA']?.trim(); + if (localAppData !== undefined && localAppData.length > 0) { + candidates.push(`${localAppData}\\Programs\\Git\\bin\\bash.exe`); + candidates.push(`${localAppData}\\Programs\\Git\\usr\\bin\\bash.exe`); + } + for (const candidate of candidates) { + checked.push(candidate); + if (await deps.isFile(candidate)) { + return candidate; + } + } + + throw new ProbeShellNotFoundError( + 'Git Bash was not found on this Windows host. Install Git for Windows from https://gitforwindows.org/ or set KIMI_SHELL_PATH to a bash.exe.', + checked, + ); +} + +async function readGitExecPath( + deps: HostEnvironmentProbeDeps, + gitExe: string, +): Promise { + if (deps.platform === 'win32' && !isAbsoluteWindowsPath(gitExe)) return undefined; + + const stdout = await deps.execFileText(gitExe, ['--exec-path'], GIT_EXEC_PATH_TIMEOUT_MS); + if (stdout === undefined) return undefined; + + for (const line of stdout.split(/\r?\n/)) { + const execPath = line.trim(); + if (execPath.length > 0) { + return execPath; + } + } + return undefined; +} + +function gitBashCandidatesFromGitExe(gitExe: string): readonly string[] | undefined { + const normalizedGitExe = nodePath.win32.normalize(normalizeWindowsPath(gitExe)); + const gitDir = nodePath.win32.dirname(normalizedGitExe); + const gitDirName = nodePath.win32.basename(gitDir).toLowerCase(); + if (gitDirName !== 'cmd' && gitDirName !== 'bin') { + return undefined; + } + return gitBashCandidatesFromGitRoot(nodePath.win32.dirname(gitDir)); +} + +function gitBashCandidatesFromGitExecPath(execPath: string): readonly string[] { + const normalized = nodePath.win32.normalize(normalizeWindowsPath(execPath)); + const parts = normalized.split('\\'); + for (let i = parts.length - 1; i >= 0; i -= 1) { + const segment = parts[i]?.toLowerCase(); + if (segment !== undefined && MINGW_PREFIX_SET.has(segment)) { + const root = parts.slice(0, i).join('\\'); + if (root.length > 0) { + return gitBashCandidatesFromGitRoot(root); + } + } + } + + return gitBashCandidatesFromGitRoot(nodePath.win32.join(normalized, '..', '..')); +} + +function gitBashCandidatesFromGitRoot(root: string): readonly string[] { + return [ + nodePath.win32.normalize(nodePath.win32.join(root, 'bin', 'bash.exe')), + nodePath.win32.normalize(nodePath.win32.join(root, 'usr', 'bin', 'bash.exe')), + ]; +} + +function normalizeWindowsPath(path: string): string { + return path.replaceAll('/', '\\'); +} + +function isAbsoluteWindowsPath(path: string): boolean { + return nodePath.win32.isAbsolute(normalizeWindowsPath(path)); +} + +function dedupeWindowsPaths(paths: readonly string[]): readonly string[] { + const deduped: string[] = []; + const seen = new Set(); + for (const path of paths) { + const key = normalizeWindowsPath(path).toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + deduped.push(path); + } + return deduped; +} + +let cachedProbe: Promise | undefined; + +export function probeHostEnvironmentFromNode(): Promise { + if (cachedProbe !== undefined) return cachedProbe; + const platform = process.platform; + const env = process.env as Record; + const isFile = async (path: string): Promise => { + try { + await access(path, fsConstants.F_OK); + return true; + } catch { + return false; + } + }; + cachedProbe = probeHostEnvironment({ + platform, + arch: process.arch, + release: nodeOs.release(), + homeDir: nodeOs.homedir(), + env, + isFile, + execFileText, + }); + return cachedProbe; +} + +async function findExecutablesOnPath( + name: string, + pathEnv: string | undefined, + platform: string, + isFile: (p: string) => Promise, +): Promise { + if (pathEnv === undefined || pathEnv.length === 0) return []; + const listSep = platform === 'win32' ? ';' : ':'; + const dirSep = platform === 'win32' ? '\\' : '/'; + const paths: string[] = []; + for (const rawDir of pathEnv.split(listSep)) { + const dir = rawDir.trim(); + if (dir.length === 0) continue; + if (platform === 'win32' && !isAbsoluteWindowsPath(dir)) continue; + const candidate = dir.endsWith(dirSep) ? `${dir}${name}` : `${dir}${dirSep}${name}`; + if (await isFile(candidate)) { + paths.push(candidate); + } + } + return platform === 'win32' ? dedupeWindowsPaths(paths) : paths; +} + +export async function execFileText( + file: string, + args: readonly string[], + timeoutMs: number, +): Promise { + return new Promise((resolve) => { + nodeExecFile( + file, + [...args], + { encoding: 'utf8', timeout: timeoutMs, windowsHide: true }, + (error, stdout) => { + if (error !== null) { + resolve(undefined); + return; + } + resolve(stdout); + }, + ); + }); +} diff --git a/packages/agent-core-v2/src/_base/execEnv/globPattern.ts b/packages/agent-core-v2/src/_base/execEnv/globPattern.ts new file mode 100644 index 0000000000000000000000000000000000000000..4efd1eb0222673277b0b296886ec1501539e5417 --- /dev/null +++ b/packages/agent-core-v2/src/_base/execEnv/globPattern.ts @@ -0,0 +1,46 @@ +export function globPatternToRegex(pattern: string, caseSensitive: boolean): RegExp { + let regex = '^'; + for (let i = 0; i < pattern.length; i++) { + const ch = pattern[i]; + if (ch === undefined) break; + switch (ch) { + case '*': + regex += '[^/]*'; + break; + case '?': + regex += '[^/]'; + break; + case '[': { + const end = pattern.indexOf(']', i + 1); + if (end === -1) { + regex += '\\['; + } else { + let charClass = pattern.slice(i + 1, end); + charClass = charClass.replace(/\\/g, '\\\\'); + if (charClass.startsWith('!')) { + charClass = '^' + charClass.slice(1); + } else if (charClass.startsWith('^')) { + charClass = '\\' + charClass; + } + regex += '[' + charClass + ']'; + i = end; + } + break; + } + case '\\': { + if (i + 1 < pattern.length) { + const next = pattern.charAt(i + 1); + regex += next.replaceAll(/[{}()+.\\[\]^$|]/g, '\\$&'); + i++; + } else { + regex += '\\\\'; + } + break; + } + default: + regex += ch.replaceAll(/[{}()+.\\[\]^$|]/g, '\\$&'); + } + } + regex += '$'; + return new RegExp(regex, caseSensitive ? '' : 'i'); +} diff --git a/packages/agent-core-v2/src/_base/execEnv/loginShellPath.ts b/packages/agent-core-v2/src/_base/execEnv/loginShellPath.ts new file mode 100644 index 0000000000000000000000000000000000000000..b72c4a499ef63311d9a1cf2ce61b08385f7f8664 --- /dev/null +++ b/packages/agent-core-v2/src/_base/execEnv/loginShellPath.ts @@ -0,0 +1,80 @@ +import { userInfo } from 'node:os'; + +import { execFileText } from './environmentProbe'; + +export interface LoginShellPathDeps { + readonly platform: string; + readonly env: Record; + readonly userShell: () => string | undefined; + readonly execFileText: ( + file: string, + args: readonly string[], + timeoutMs: number, + ) => Promise; +} + +const LOGIN_SHELL_ENV_TIMEOUT_MS = 5_000; + +export async function probeLoginShellPath(deps: LoginShellPathDeps): Promise { + if (deps.platform === 'win32') return undefined; + const envShell = deps.env['SHELL']?.trim(); + const shell = envShell === undefined || envShell.length === 0 ? deps.userShell() : envShell; + if (shell === undefined || shell.length === 0) return undefined; + + const stdout = await deps.execFileText(shell, ['-l', '-c', '/usr/bin/env'], LOGIN_SHELL_ENV_TIMEOUT_MS); + if (stdout === undefined) return undefined; + + let path: string | undefined; + for (const line of stdout.split('\n')) { + if (line.startsWith('PATH=')) { + path = line.slice('PATH='.length).trim(); + } + } + if (path === undefined || path.length === 0) return undefined; + return path; +} + +export function mergeLoginShellPath(currentPath: string | undefined, loginShellPath: string): string { + const current = currentPath ?? ''; + const seen = new Set(current.split(':').filter((entry) => entry.length > 0)); + const additions: string[] = []; + for (const entry of loginShellPath.split(':')) { + if (!entry.startsWith('/') || seen.has(entry)) continue; + seen.add(entry); + additions.push(entry); + } + if (additions.length === 0) return current; + if (currentPath === undefined) return additions.join(':'); + return `${current}:${additions.join(':')}`; +} + +export async function applyLoginShellPath(deps: LoginShellPathDeps): Promise { + const loginShellPath = await probeLoginShellPath(deps); + if (loginShellPath === undefined) return; + const currentPath = deps.env['PATH']; + const merged = mergeLoginShellPath(currentPath, loginShellPath); + if (merged === (currentPath ?? '')) return; + deps.env['PATH'] = merged; +} + +function userShellFromNode(): string | undefined { + try { + const shell = userInfo().shell; + return shell === null || shell.length === 0 ? undefined : shell; + } catch { + return undefined; + } +} + +let appliedLoginShellPath: Promise | undefined; + +export function applyLoginShellPathFromNode(): Promise { + if (appliedLoginShellPath !== undefined) return appliedLoginShellPath; + appliedLoginShellPath = applyLoginShellPath({ + platform: process.platform, + env: process.env as Record, + userShell: userShellFromNode, + execFileText, + }); + return appliedLoginShellPath; +} diff --git a/packages/agent-core-v2/src/_base/execEnv/shellPathBridge.ts b/packages/agent-core-v2/src/_base/execEnv/shellPathBridge.ts new file mode 100644 index 0000000000000000000000000000000000000000..7303c5d87867b23ee5a8e79fa0da24150abc032d --- /dev/null +++ b/packages/agent-core-v2/src/_base/execEnv/shellPathBridge.ts @@ -0,0 +1,150 @@ +import { execFileSync as nodeExecFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import * as nodePath from 'node:path'; + +import type { HostEnvironmentInfo } from './environmentProbe'; + +export interface ShellPathBridge { + toShellPath(nativePath: string): string; + fromShellPath(path: string): string; +} + +export type ShellPathBridgeEnv = Pick; + +export interface ShellPathBridgeDeps { + readonly execFileSync: (file: string, args: readonly string[]) => string; + readonly isFile: (path: string) => boolean; +} + +const CYGPATH_TIMEOUT_MS = 5_000; + +const DRIVE_COLON_RE = /^\/([a-zA-Z]):(?:[\\/]|$)/; +const CYGDRIVE_RE = /^\/cygdrive\/([a-zA-Z])(?:\/|$)/; +const DRIVE_RE = /^\/([a-zA-Z])(?:\/|$)/; + +const VIRTUAL_FS_PREFIXES: readonly string[] = ['/dev/', '/proc/', '/sys/']; + +const WIN32_DRIVE_ABSOLUTE_RE = /^[A-Za-z]:[\\/]/; + +function joinDrive(letter: string, rest: string): string { + const normalizedRest = rest.replaceAll('\\', '/'); + return normalizedRest === '' + ? `${letter.toUpperCase()}:/` + : `${letter.toUpperCase()}:${normalizedRest}`; +} + +export function translateShellDrivePath(path: string): string { + const colonMatch = DRIVE_COLON_RE.exec(path); + if (colonMatch !== null) { + return joinDrive(colonMatch[1]!, path.slice(3)); + } + const cygdriveMatch = CYGDRIVE_RE.exec(path); + if (cygdriveMatch !== null) { + return joinDrive(cygdriveMatch[1]!, path.slice(`/cygdrive/${cygdriveMatch[1]!}`.length)); + } + const driveMatch = DRIVE_RE.exec(path); + if (driveMatch !== null) { + return joinDrive(driveMatch[1]!, path.slice(2)); + } + return path; +} + +export function createShellPathBridge( + env: ShellPathBridgeEnv, + deps: ShellPathBridgeDeps, +): ShellPathBridge { + const enabled = env.osKind === 'Windows' && env.shellName === 'bash'; + + let cygpathExe: string | null | undefined; + const segmentCache = new Map(); + + function locateCygpath(): string | null { + if (cygpathExe !== undefined) return cygpathExe; + const shellDir = nodePath.win32.dirname(env.shellPath); + const candidates = [nodePath.win32.join(shellDir, 'cygpath.exe')]; + if (nodePath.win32.basename(shellDir).toLowerCase() === 'bin') { + candidates.push(nodePath.win32.join(shellDir, '..', 'usr', 'bin', 'cygpath.exe')); + } + cygpathExe = candidates.find((candidate) => deps.isFile(candidate)) ?? null; + return cygpathExe; + } + + function resolveRootSegment(firstSegment: string): string | null { + const cached = segmentCache.get(firstSegment); + if (cached !== undefined) return cached; + + const exe = locateCygpath(); + if (exe === null) return null; + let resolved: string; + try { + const output = deps.execFileSync(exe, ['-w', '-C', 'UTF8', '--', `/${firstSegment}`]); + const trimmed = output.replace(/\r?\n$/, ''); + if (!WIN32_DRIVE_ABSOLUTE_RE.test(trimmed) && !trimmed.startsWith('\\\\')) return null; + resolved = trimmed.replace(/[\\/]$/, ''); + } catch { + return null; + } + segmentCache.set(firstSegment, resolved); + return resolved; + } + + function fromShellPath(path: string): string { + if (!enabled) return path; + + if (path.startsWith('//')) return path; + + if (path.startsWith('/')) { + const normalized = nodePath.posix.normalize(path); + const lexical = translateShellDrivePath(normalized); + if (lexical !== normalized) return lexical; + if (normalized === '/') return normalized; + if (VIRTUAL_FS_PREFIXES.some((prefix) => normalized.startsWith(prefix))) return normalized; + const firstSegment = normalized.slice(1).split('/')[0]!; + const prefix = resolveRootSegment(firstSegment); + if (prefix === null) return normalized; + const remainder = normalized.slice(firstSegment.length + 1); + const joined = `${prefix}${remainder}`.replaceAll('\\', '/'); + return /^[A-Za-z]:$/.test(joined) ? `${joined}/` : joined; + } + + return path; + } + + function toShellPath(nativePath: string): string { + if (!enabled) return nativePath; + + if (nativePath.startsWith('\\\\')) { + return nativePath.replaceAll('\\', '/'); + } + + const driveMatch = /^([A-Za-z]):(?:[\\/]|$)/.exec(nativePath); + if (driveMatch !== null) { + const drive = driveMatch[1]!.toLowerCase(); + const rest = nativePath.slice(2).replaceAll('\\', '/'); + return `/${drive}${rest.startsWith('/') ? rest : `/${rest}`}`; + } + + return nativePath.replaceAll('\\', '/'); + } + + return { toShellPath, fromShellPath }; +} + +const bridgeCache = new Map(); + +export function getShellPathBridge(env: ShellPathBridgeEnv): ShellPathBridge { + const key = `${env.osKind} ${env.shellName} ${env.shellPath}`; + const cached = bridgeCache.get(key); + if (cached !== undefined) return cached; + const bridge = createShellPathBridge(env, { + execFileSync: (file, args) => + nodeExecFileSync(file, [...args], { + encoding: 'utf8', + timeout: CYGPATH_TIMEOUT_MS, + windowsHide: true, + }), + isFile: (path) => existsSync(path), + }); + bridgeCache.set(key, bridge); + return bridge; +} diff --git a/packages/agent-core-v2/src/_base/lifecycle/disposer.ts b/packages/agent-core-v2/src/_base/lifecycle/disposer.ts new file mode 100644 index 0000000000000000000000000000000000000000..6e7a9a5667edd70e4ac2240f132a17f50647bb16 --- /dev/null +++ b/packages/agent-core-v2/src/_base/lifecycle/disposer.ts @@ -0,0 +1,37 @@ +export type TeardownReason = 'scope-close' | 'cascade' | 'unload'; + +export type Disposer = (reason: TeardownReason) => void | Promise; + +export type EffectResult = + | void + | Disposer + | Promise + | Iterable + | AsyncIterable; + +export type EffectBody = () => EffectResult; + +export function isPromiseLike(value: unknown): value is PromiseLike { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { then?: unknown }).then === 'function' + ); +} + +export function isSyncIterable(value: unknown): value is Iterable { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { [Symbol.iterator]?: unknown })[Symbol.iterator] === 'function' + ); +} + +export function isAsyncIterable(value: unknown): value is AsyncIterable { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === + 'function' + ); +} diff --git a/packages/agent-core-v2/src/_base/lifecycle/errors.ts b/packages/agent-core-v2/src/_base/lifecycle/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..e210bbddab7dbcc56515f113fa107836ee39a942 --- /dev/null +++ b/packages/agent-core-v2/src/_base/lifecycle/errors.ts @@ -0,0 +1,12 @@ +export class LedgerDisposedError extends Error { + constructor( + readonly ledgerLabel: string, + readonly operation: string, + readonly ledgerState: 'disposing' | 'disposed', + ) { + super( + `Ledger '${ledgerLabel}' is ${ledgerState}: cannot ${operation} on a ${ledgerState} ledger`, + ); + this.name = 'LedgerDisposedError'; + } +} diff --git a/packages/agent-core-v2/src/_base/lifecycle/index.ts b/packages/agent-core-v2/src/_base/lifecycle/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0fcdf1c520539d929f05e96870851a28d214cff6 --- /dev/null +++ b/packages/agent-core-v2/src/_base/lifecycle/index.ts @@ -0,0 +1,4 @@ +export * from './disposer'; +export * from './errors'; +export * from './ledger'; +export * from './lifecycleMachine'; diff --git a/packages/agent-core-v2/src/_base/lifecycle/keyedResource.ts b/packages/agent-core-v2/src/_base/lifecycle/keyedResource.ts new file mode 100644 index 0000000000000000000000000000000000000000..14d79adf6b3e46e1ddd58f76d109fd6eddb0566e --- /dev/null +++ b/packages/agent-core-v2/src/_base/lifecycle/keyedResource.ts @@ -0,0 +1,150 @@ +export interface KeyedResourceGeneration { + readonly owner: string; + readonly generation: string | number; +} + +export interface KeyedResource { + dispose(): void | Promise; + abort?(reason?: unknown): void; +} + +export interface KeyedResourceLease { + readonly resource: Resource; + release(): void; +} + +interface ResourceEntry { + promise: Promise; + resource?: Resource; + leases: number; + draining: boolean; + abortOnDrain: boolean; + aborted: boolean; + disposed: boolean; + drainPromise?: Promise; + releaseDrain?: () => void; +} + +export class KeyedResourceLeasePool { + private readonly entries = new Map>(); + private withdrawn = false; + private withdrawal?: Promise; + + constructor( + readonly identity: KeyedResourceGeneration, + private readonly create: (key: Key) => Resource | Promise, + ) {} + + acquire(key: Key): Promise> { + if (this.withdrawn) return Promise.reject(this.unavailable()); + let entry = this.entries.get(key); + if (entry === undefined) { + entry = this.createEntry(key); + this.entries.set(key, entry); + } + if (entry.draining) return Promise.reject(this.unavailable()); + entry.leases += 1; + return entry.promise.then( + (resource) => { + let active = true; + return { + resource, + release: () => { + if (!active) return; + active = false; + entry.leases -= 1; + if (entry.leases === 0) entry.releaseDrain?.(); + }, + }; + }, + (error: unknown) => { + entry.leases -= 1; + if (entry.leases === 0) entry.releaseDrain?.(); + throw error; + }, + ); + } + + has(key: Key): boolean { + return this.entries.has(key); + } + + disposeKey(key: Key, reason?: unknown, abort = false): Promise { + const entry = this.entries.get(key); + if (entry === undefined) return Promise.resolve(); + this.entries.delete(key); + return this.drain(entry, reason, abort); + } + + withdraw(reason?: unknown): Promise { + if (this.withdrawal !== undefined) return this.withdrawal; + this.withdrawn = true; + const entries = [...this.entries.values()]; + this.entries.clear(); + this.withdrawal = Promise.all(entries.map((entry) => this.drain(entry, reason, false))).then( + () => undefined, + ); + return this.withdrawal; + } + + private createEntry(key: Key): ResourceEntry { + const entry: ResourceEntry = { + promise: undefined as unknown as Promise, + leases: 0, + draining: false, + abortOnDrain: false, + aborted: false, + disposed: false, + }; + entry.promise = Promise.resolve() + .then(() => this.create(key)) + .then( + (resource) => { + entry.resource = resource; + if (entry.abortOnDrain) this.abort(entry); + return resource; + }, + (error: unknown) => { + if (this.entries.get(key) === entry) this.entries.delete(key); + throw error; + }, + ); + return entry; + } + + private drain(entry: ResourceEntry, reason?: unknown, abort = false): Promise { + entry.abortOnDrain ||= abort; + entry.drainPromise ??= (async () => { + entry.draining = true; + try { + await entry.promise; + } catch { + return; + } + if (entry.abortOnDrain) this.abort(entry, reason); + if (entry.leases > 0) { + await new Promise((resolve) => { + entry.releaseDrain = resolve; + }); + } + if (entry.disposed) return; + entry.disposed = true; + await entry.resource!.dispose(); + })(); + return entry.drainPromise; + } + + private abort(entry: ResourceEntry, reason?: unknown): void { + if (entry.aborted || entry.resource?.abort === undefined) return; + entry.aborted = true; + try { + entry.resource.abort(reason); + } catch {} + } + + private unavailable(): Error { + return new Error( + `resource generation ${this.identity.owner}:${String(this.identity.generation)} is withdrawn`, + ); + } +} diff --git a/packages/agent-core-v2/src/_base/lifecycle/ledger.ts b/packages/agent-core-v2/src/_base/lifecycle/ledger.ts new file mode 100644 index 0000000000000000000000000000000000000000..d49c9ce0786cdc17ee27d568561fa06feae9ee02 --- /dev/null +++ b/packages/agent-core-v2/src/_base/lifecycle/ledger.ts @@ -0,0 +1,317 @@ +import { onUnexpectedError } from '../errors/unexpectedError'; +import { + isAsyncIterable, + isPromiseLike, + isSyncIterable, + type Disposer, + type EffectBody, + type TeardownReason, +} from './disposer'; +import { LedgerDisposedError } from './errors'; + +export type LedgerState = 'active' | 'disposing' | 'disposed'; + +export interface LedgerEntryInfo { + readonly label: string; + readonly kind: 'disposer' | 'effect' | 'ledger'; + readonly stack?: string; + readonly children?: readonly LedgerEntryInfo[]; +} + +export interface LedgerEntry { + readonly label: string; + readonly disposed: boolean; + dispose(reason?: TeardownReason): void | Promise; + release(): void; +} + +interface EntryRecord { + label: string; + kind: 'disposer' | 'effect' | 'ledger'; + stack?: string; + active: boolean; + run: Disposer; + ledger?: Ledger; +} + +export class Ledger { + static captureStacks = false; + + private _state: LedgerState = 'active'; + private readonly _records: EntryRecord[] = []; + private _teardownPromise: Promise | undefined; + private _parentEntry: LedgerEntry | undefined; + + constructor(readonly label: string = 'ledger') {} + + get state(): LedgerState { + return this._state; + } + + get isActive(): boolean { + return this._state === 'active'; + } + + get isDisposed(): boolean { + return this._state === 'disposed'; + } + + get size(): number { + return this._records.reduce((count, record) => count + (record.active ? 1 : 0), 0); + } + + register(disposer: Disposer, label: string = 'disposer'): LedgerEntry { + this._assertActive('register'); + return this._push({ label, kind: 'disposer', active: true, run: disposer }); + } + + registerFinalizer(disposer: Disposer, label: string = 'finalizer'): LedgerEntry { + this._assertActive('registerFinalizer'); + return this._push({ label, kind: 'disposer', active: true, run: disposer }, true); + } + + effect(body: EffectBody, label: string = 'effect'): LedgerEntry { + this._assertActive('effect'); + const out = body(); + if (typeof out === 'function') { + return this._push({ label, kind: 'effect', active: true, run: out }); + } + if (isPromiseLike(out)) { + const promise = Promise.resolve(out); + return this._push({ + label, + kind: 'effect', + active: true, + run: async (reason) => { + const disposer = await promise; + if (typeof disposer === 'function') await disposer(reason); + }, + }); + } + if (isAsyncIterable(out)) { + const promise = driveAsyncEffect(out); + return this._push({ + label, + kind: 'effect', + active: true, + run: async (reason) => { + const disposer = await promise; + await disposer(reason); + }, + }); + } + if (isSyncIterable(out)) { + const run = driveSyncEffect(out); + return this._push({ label, kind: 'effect', active: true, run }); + } + return this._push({ label, kind: 'effect', active: true, run: () => {} }); + } + + createChild(label: string = 'ledger'): Ledger { + this._assertActive('createChild'); + const child = new Ledger(label); + child._parentEntry = this._push({ + label, + kind: 'ledger', + active: true, + run: (reason) => child.teardown(reason), + ledger: child, + }); + return child; + } + + teardown(reason: TeardownReason = 'scope-close'): void | Promise { + if (this._state !== 'active') { + return this._teardownPromise; + } + this._state = 'disposing'; + this._detachFromParent(); + const out = drainRecords(this._records, reason); + if (isPromiseLike(out)) { + this._teardownPromise = Promise.resolve(out).then(() => { + this._state = 'disposed'; + }); + return this._teardownPromise; + } + this._state = 'disposed'; + return undefined; + } + + clear(reason: TeardownReason = 'scope-close'): void | Promise { + this._assertActive('clear'); + return drainRecords(this._records, reason); + } + + entries(): LedgerEntryInfo[] { + const infos: LedgerEntryInfo[] = []; + for (const record of this._records) { + if (!record.active) continue; + infos.push({ + label: record.label, + kind: record.kind, + stack: record.stack, + children: record.ledger?.entries(), + }); + } + return infos; + } + + private _push(record: EntryRecord, front = false): LedgerEntry { + if (Ledger.captureStacks) { + record.stack = new Error('Ledger registration').stack; + } + if (front) { + this._records.unshift(record); + } else { + this._records.push(record); + } + return { + label: record.label, + get disposed() { + return !record.active; + }, + dispose: (reason: TeardownReason = 'scope-close') => { + if (!record.active) return undefined; + record.active = false; + this._remove(record); + return runGuarded(record, reason); + }, + release: () => { + if (!record.active) return; + record.active = false; + this._remove(record); + }, + }; + } + + private _remove(record: EntryRecord): void { + const index = this._records.indexOf(record); + if (index >= 0) { + this._records.splice(index, 1); + } + } + + private _detachFromParent(): void { + this._parentEntry?.release(); + this._parentEntry = undefined; + } + + private _assertActive(operation: string): void { + if (this._state !== 'active') { + throw new LedgerDisposedError(this.label, operation, this._state); + } + } +} + +function runGuarded(record: EntryRecord, reason: TeardownReason): void | Promise { + let out: void | Promise; + try { + out = record.run(reason); + } catch (error) { + onUnexpectedError(tagged(error, record.label)); + return undefined; + } + if (isPromiseLike(out)) { + return Promise.resolve(out).catch((error: unknown) => { + onUnexpectedError(tagged(error, record.label)); + }); + } + return undefined; +} + +function tagged(error: unknown, label: string): unknown { + if (error instanceof Error) { + error.message = `[ledger:${label}] ${error.message}`; + return error; + } + return new Error(`[ledger:${label}] ${String(error)}`); +} + +function drainRecords(records: EntryRecord[], reason: TeardownReason): void | Promise { + let index = records.length; + const step = (): void | Promise => { + while (index > 0) { + index -= 1; + const record = records[index]!; + if (!record.active) continue; + record.active = false; + const out = runGuarded(record, reason); + if (isPromiseLike(out)) { + return Promise.resolve(out).then(step); + } + } + records.length = 0; + return undefined; + }; + return step(); +} + +function runDisposersReverse( + disposers: readonly Disposer[], + reason: TeardownReason, +): void | Promise { + let index = disposers.length; + const step = (): void | Promise => { + while (index > 0) { + index -= 1; + const disposer = disposers[index]!; + let out: void | Promise; + try { + out = disposer(reason); + } catch (error) { + onUnexpectedError(tagged(error, 'effect')); + continue; + } + if (isPromiseLike(out)) { + return Promise.resolve(out) + .catch((error: unknown) => { onUnexpectedError(tagged(error, 'effect')); }) + .then(step); + } + } + return undefined; + }; + return step(); +} + +function collect(step: IteratorResult, disposers: Disposer[]): void { + if (typeof step.value === 'function') { + disposers.push(step.value); + } +} + +function driveSyncEffect(iterable: Iterable): Disposer { + const disposers: Disposer[] = []; + const iterator = iterable[Symbol.iterator](); + try { + let step = iterator.next(); + while (!step.done) { + collect(step, disposers); + step = iterator.next(); + } + collect(step, disposers); + } catch (error) { + const rollback = runDisposersReverse(disposers, 'unload'); + if (isPromiseLike(rollback)) { + Promise.resolve(rollback).catch((error: unknown) => { onUnexpectedError(error); }); + } + throw error; + } + return (reason) => runDisposersReverse(disposers, reason); +} + +async function driveAsyncEffect(iterable: AsyncIterable): Promise { + const disposers: Disposer[] = []; + const iterator = iterable[Symbol.asyncIterator](); + try { + let step = await iterator.next(); + while (!step.done) { + collect(step, disposers); + step = await iterator.next(); + } + collect(step, disposers); + } catch (error) { + await runDisposersReverse(disposers, 'unload'); + throw error; + } + return (reason) => runDisposersReverse(disposers, reason); +} diff --git a/packages/agent-core-v2/src/_base/lifecycle/lifecycleMachine.ts b/packages/agent-core-v2/src/_base/lifecycle/lifecycleMachine.ts new file mode 100644 index 0000000000000000000000000000000000000000..784aab79722c9aa51eea218dc9c4ee18a4b4811d --- /dev/null +++ b/packages/agent-core-v2/src/_base/lifecycle/lifecycleMachine.ts @@ -0,0 +1,235 @@ +export type LifecycleTransitionErrorReason = + | 'invalid_state' + | 'transition_conflict' + | 'missing_commit_state' + | 'missing_rollback_state' + | 'already_committed' + | 'already_rolled_back'; + +export interface LifecycleTransitionErrorOptions { + readonly reason: LifecycleTransitionErrorReason; + readonly operation: string; + readonly state: TState; + readonly expected?: TState | readonly TState[]; + readonly activeOperation?: string; +} + +export class LifecycleTransitionError extends Error { + readonly reason: LifecycleTransitionErrorReason; + readonly operation: string; + readonly state: TState; + readonly expected?: TState | readonly TState[]; + readonly activeOperation?: string; + + constructor(options: LifecycleTransitionErrorOptions) { + super(formatTransitionError(options)); + this.name = 'LifecycleTransitionError'; + this.reason = options.reason; + this.operation = options.operation; + this.state = options.state; + this.expected = options.expected; + this.activeOperation = options.activeOperation; + } +} + +export interface LifecycleSnapshot { + readonly state: TState; + readonly transitioning: boolean; + readonly operation?: string; +} + +export interface LifecycleSwitchOptions { + readonly operation: string; + readonly from: TState | readonly TState[]; + readonly to: TState; +} + +export interface LifecycleTransactionOptions { + readonly operation: string; + readonly from: TState | readonly TState[]; + readonly enter: TState; + readonly commit?: TState; + readonly rollback?: TState; +} + +export interface LifecycleTransaction { + defer(callback: LifecycleAction): void; + rollback(callback: LifecycleAction): void; + afterCommit(callback: LifecycleAction): void; + commit(state: TState): void; + rollbackTo(state: TState): void; +} + +export type LifecycleAction = () => void | Promise; + +export class LifecycleMachine { + private _state: TState; + private activeOperation: string | undefined; + + constructor(initial: TState) { + this._state = initial; + } + + get state(): TState { + return this._state; + } + + get snapshot(): LifecycleSnapshot { + return this.activeOperation === undefined + ? { state: this._state, transitioning: false } + : { + state: this._state, + transitioning: true, + operation: this.activeOperation, + }; + } + + is(...states: readonly TState[]): boolean { + return states.includes(this._state); + } + + switch(options: LifecycleSwitchOptions): void { + this.assertIdle(options.operation); + this.assertState(options.operation, options.from); + this._state = options.to; + } + + async transaction( + options: LifecycleTransactionOptions, + callback: (transaction: LifecycleTransaction) => TResult | Promise, + ): Promise { + this.assertIdle(options.operation); + this.assertState(options.operation, options.from); + + this.activeOperation = options.operation; + this._state = options.enter; + + const deferred: LifecycleAction[] = []; + const rollbacks: LifecycleAction[] = []; + const afterCommit: LifecycleAction[] = []; + let commitState = options.commit; + let rollbackState = options.rollback; + let commitSelected = false; + let rollbackSelected = false; + + const transaction: LifecycleTransaction = { + defer: (action) => deferred.push(action), + rollback: (action) => rollbacks.push(action), + afterCommit: (action) => afterCommit.push(action), + commit: (state) => { + if (commitSelected) { + throw this.createError(options.operation, 'already_committed'); + } + commitSelected = true; + commitState = state; + }, + rollbackTo: (state) => { + if (rollbackSelected) { + throw this.createError(options.operation, 'already_rolled_back'); + } + rollbackSelected = true; + rollbackState = state; + }, + }; + + try { + let result: TResult; + try { + result = await callback(transaction); + } catch (error) { + const errors: unknown[] = [ + error, + ...(await runActions(rollbacks)), + ...(await runActions(deferred)), + ]; + + if (rollbackState === undefined) { + errors.push(this.createError(options.operation, 'missing_rollback_state')); + } else { + this._state = rollbackState; + } + throw aggregateErrors(errors, `Lifecycle transaction "${options.operation}" failed`); + } + + if (commitState === undefined) { + throw this.createError(options.operation, 'missing_commit_state'); + } + + const cleanupErrors = await runActions(deferred); + this._state = commitState; + const afterCommitErrors = await runActions(afterCommit); + const errors = [...cleanupErrors, ...afterCommitErrors]; + if (errors.length > 0) { + throw aggregateErrors( + errors, + `Lifecycle transaction "${options.operation}" committed with action failures`, + ); + } + return result; + } finally { + this.activeOperation = undefined; + } + } + + private assertIdle(operation: string): void { + if (this.activeOperation === undefined) return; + throw this.createError(operation, 'transition_conflict', undefined, this.activeOperation); + } + + private assertState(operation: string, expected: TState | readonly TState[]): void { + const states = Array.isArray(expected) ? expected : [expected]; + if (states.includes(this._state)) return; + throw this.createError(operation, 'invalid_state', expected); + } + + private createError( + operation: string, + reason: LifecycleTransitionErrorReason, + expected?: TState | readonly TState[], + activeOperation?: string, + ): LifecycleTransitionError { + return new LifecycleTransitionError({ + reason, + operation, + state: this._state, + expected, + activeOperation, + }); + } +} + +async function runActions(actions: readonly LifecycleAction[]): Promise { + const errors: unknown[] = []; + for (let index = actions.length - 1; index >= 0; index -= 1) { + try { + await actions[index]!(); + } catch (error) { + errors.push(error); + } + } + return errors; +} + +function aggregateErrors(errors: readonly unknown[], message: string): unknown { + if (errors.length === 1) return errors[0]; + return new AggregateError(errors, message, { cause: errors[0] }); +} + +function formatTransitionError( + options: LifecycleTransitionErrorOptions, +): string { + switch (options.reason) { + case 'invalid_state': + return `Lifecycle operation "${options.operation}" is not allowed from state "${options.state}"`; + case 'transition_conflict': + return `Lifecycle operation "${options.operation}" conflicts with active operation "${options.activeOperation}"`; + case 'missing_commit_state': + return `Lifecycle operation "${options.operation}" did not select a commit state`; + case 'missing_rollback_state': + return `Lifecycle operation "${options.operation}" did not select a rollback state`; + case 'already_committed': + return `Lifecycle operation "${options.operation}" already selected a commit state`; + case 'already_rolled_back': + return `Lifecycle operation "${options.operation}" already selected a rollback state`; + } +} diff --git a/packages/agent-core-v2/src/_base/log/fileLog.ts b/packages/agent-core-v2/src/_base/log/fileLog.ts new file mode 100644 index 0000000000000000000000000000000000000000..e52c065baf295f623a7982c7a59b35d6c82eb4fe --- /dev/null +++ b/packages/agent-core-v2/src/_base/log/fileLog.ts @@ -0,0 +1,285 @@ +import { appendFileSync, mkdirSync } from 'node:fs'; +import { mkdir, open, rename, stat, unlink } from 'node:fs/promises'; +import { dirname } from 'pathe'; + +import { formatEntry, type FormatOptions } from './formatter'; +import type { ILogWriter, LogEntry } from './log'; + +export const PENDING_MAX = 1000; +const STDERR_NOTICE_INTERVAL_MS = 30_000; + +class AsyncSerialQueue { + private tail: Promise = Promise.resolve(); + + run(task: () => Promise): Promise { + const next = this.tail.then(task, task); + this.tail = next.catch(() => {}); + return next; + } +} + +export interface RotatingFileWriterOptions { + readonly path: string; + readonly maxBytes: number; + readonly files: number; +} + +async function syncDir(dirPath: string): Promise { + if (process.platform === 'win32') return; + const dirFh = await open(dirPath, 'r'); + try { + await dirFh.sync(); + } finally { + await dirFh.close(); + } +} + +export class RotatingFileWriter { + private readonly queue = new AsyncSerialQueue(); + private pending: string[] = []; + private dropped = 0; + private closed = false; + private lastStderrNotice = 0; + private currentBytes = -1; + private directorySynced = false; + + constructor(private readonly options: RotatingFileWriterOptions) {} + + enqueue(line: string): void { + if (this.closed) return; + if (this.pending.length >= PENDING_MAX) { + this.pending.shift(); + this.dropped++; + } + this.pending.push(line); + this.scheduleDrain(); + } + + async flush(): Promise { + return this.queue.run(() => this.drain()); + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + try { + await this.flush(); + } catch { + } + } + + flushSync(): void { + if (this.closed || this.pending.length === 0) return; + try { + mkdirSync(dirname(this.options.path), { recursive: true }); + const body = this.pending.join('') + this.takeDroppedNotice(); + this.pending = []; + appendFileSync(this.options.path, body); + } catch (error) { + this.noteFailure(error); + } + } + + private scheduleDrain(): void { + if (this.closed) return; + queueMicrotask(() => { + if (this.closed || this.pending.length === 0) return; + this.queue.run(() => this.drain()).catch(() => {}); + }); + } + + private async drain(): Promise { + if (this.pending.length === 0) return true; + const droppedLine = this.takeDroppedNotice(); + const lines = droppedLine === '' ? [...this.pending] : [...this.pending, droppedLine]; + this.pending = []; + try { + await mkdir(dirname(this.options.path), { recursive: true }); + if (this.currentBytes < 0) { + this.currentBytes = await this.statSize(this.options.path); + } + await this.appendLines(lines); + + if (!this.directorySynced) { + await syncDir(dirname(this.options.path)); + this.directorySynced = true; + } + + return true; + } catch (error) { + this.noteFailure(error); + this.restorePending(lines); + return false; + } + } + + private restorePending(lines: readonly string[]): void { + const restored = [...lines, ...this.pending]; + const overflow = restored.length - PENDING_MAX; + if (overflow <= 0) { + this.pending = restored; + return; + } + this.dropped += overflow; + this.pending = restored.slice(overflow); + } + + private async appendLines(lines: readonly string[]): Promise { + let chunk = ''; + let chunkBytes = 0; + for (const line of lines) { + const lineBytes = Buffer.byteLength(line, 'utf-8'); + if ( + chunkBytes > 0 && + (chunkBytes + lineBytes > this.options.maxBytes || + this.currentBytes + chunkBytes + lineBytes > this.options.maxBytes) + ) { + await this.appendChunk(chunk); + chunk = ''; + chunkBytes = 0; + } + + if ( + chunkBytes === 0 && + this.currentBytes > 0 && + this.currentBytes + lineBytes > this.options.maxBytes + ) { + await this.rotate(); + } + + chunk += line; + chunkBytes += lineBytes; + } + if (chunkBytes > 0) { + await this.appendChunk(chunk); + } + } + + private async appendChunk(chunk: string): Promise { + const fh = await open(this.options.path, 'a'); + try { + await fh.appendFile(chunk, 'utf-8'); + await fh.sync(); + } finally { + await fh.close(); + } + this.currentBytes += Buffer.byteLength(chunk, 'utf-8'); + if (this.currentBytes >= this.options.maxBytes) { + await this.rotate(); + } + } + + private async rotate(): Promise { + const { path, files } = this.options; + for (let i = files - 2; i >= 1; i--) { + const from = `${path}.${i}`; + const to = `${path}.${i + 1}`; + try { + await rename(from, to); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } + try { + await rename(path, `${path}.1`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + try { + await unlink(`${path}.${files}`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + this.currentBytes = 0; + this.directorySynced = false; + } + + private async statSize(p: string): Promise { + try { + const s = await stat(p); + return s.size; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 0; + throw error; + } + } + + private takeDroppedNotice(): string { + if (this.dropped === 0) return ''; + const line = `... dropped ${this.dropped} entries ...\n`; + this.dropped = 0; + return line; + } + + private noteFailure(error: unknown): void { + const now = Date.now(); + if (now - this.lastStderrNotice < STDERR_NOTICE_INTERVAL_MS) return; + this.lastStderrNotice = now; + const code = (error as NodeJS.ErrnoException)?.code ?? 'UNKNOWN'; + try { + process.stderr.write(`[logger] write failed: ${code}\n`); + } catch {} + } +} + +export interface FileLogWriterOptions extends RotatingFileWriterOptions { + readonly format?: FormatOptions; +} + +export class FileLogWriter implements ILogWriter { + private readonly sink: RotatingFileWriter; + private readonly format: FormatOptions; + + constructor(options: FileLogWriterOptions) { + this.sink = new RotatingFileWriter(options); + this.format = options.format ?? {}; + } + + write(entry: LogEntry): void { + const { text, dropped } = formatEntry(entry, this.format); + if (dropped) return; + this.sink.enqueue(text + '\n'); + } + + async flush(): Promise { + await this.sink.flush(); + } + + close(): Promise { + return this.sink.close(); + } + + flushSync(): void { + this.sink.flushSync(); + } +} + +export function createFileLogWriter(options: FileLogWriterOptions): FileLogWriter { + return new FileLogWriter(options); +} + +export class MemoryLogWriter implements ILogWriter { + readonly entries: LogEntry[] = []; + write(entry: LogEntry): void { + this.entries.push(entry); + } +} + +export class ConsoleLogWriter implements ILogWriter { + write(entry: LogEntry): void { + const { text } = formatEntry(entry, { ansi: process.stderr.isTTY === true }); + switch (entry.level) { + case 'error': + console.error(text); + break; + case 'warn': + console.warn(text); + break; + case 'debug': + console.debug(text); + break; + default: + console.log(text); + } + } +} diff --git a/packages/agent-core-v2/src/_base/log/formatter.ts b/packages/agent-core-v2/src/_base/log/formatter.ts new file mode 100644 index 0000000000000000000000000000000000000000..c2bb51e3e38c2a9aac25df5a996389be9b385f57 --- /dev/null +++ b/packages/agent-core-v2/src/_base/log/formatter.ts @@ -0,0 +1,196 @@ +import type { LogContext, LogEntry, LogEntryError } from './log'; + +export const MSG_MAX_CHARS = 200; +export const CTX_VALUE_MAX_CHARS = 2048; +export const STACK_MAX_BYTES = 2048; +export const ENTRY_MAX_BYTES = 4096; +export const REDACT_MAX_DEPTH = 10; + +const REDACTED_KEYS: ReadonlySet = new Set([ + 'authorization', + 'apikey', + 'token', + 'refreshtoken', + 'accesstoken', + 'idtoken', + 'password', + 'secret', + 'clientsecret', + 'apisecret', + 'cookie', + 'setcookie', + 'bearer', +]); + +const SAFE_KEY_RE = /^[\w.-]+$/; +const ELLIPSIS = '…'; +const TRUNCATED_TAIL = ` …truncated`; +const REDACTED = '[REDACTED]'; +const RAW_SECRET_PATTERNS: readonly RegExp[] = [ + /\b(authorization\s*[:=]\s*bearer\s+)[^\s"'`]+/gi, + /\b((?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|secret)\s*[:=]\s*)[^\s"'`]+/gi, + /\b(cookie\s*[:=]\s*)[^\r\n]+/gi, +]; + +const LEVEL_LABEL: Record, string> = { + error: 'ERROR', + warn: 'WARN ', + info: 'INFO ', + debug: 'DEBUG', +}; + +const ANSI_LEVEL: Record, string> = { + error: '', + warn: '', + info: '', + debug: '', +}; +const ANSI_RESET = ''; + +function normalizeKey(key: string): string { + return key.toLowerCase().replaceAll(/[_\-.]/g, ''); +} + +export function redactCtx(ctx: LogContext): LogContext { + const seen = new WeakSet(); + const walk = (value: unknown, depth: number): unknown => { + if (depth > REDACT_MAX_DEPTH) return '[REDACTED:depth]'; + if (value === null || typeof value !== 'object') return value; + if (seen.has(value)) return '[REDACTED:cycle]'; + seen.add(value); + if (Array.isArray(value)) { + return value.map((item) => walk(item, depth + 1)); + } + const out: Record = {}; + for (const [key, raw] of Object.entries(value as Record)) { + out[key] = REDACTED_KEYS.has(normalizeKey(key)) ? REDACTED : walk(raw, depth + 1); + } + return out; + }; + return walk(ctx, 0) as LogContext; +} + +export interface FormatOptions { + readonly ansi?: boolean; + readonly omitContextKeys?: readonly string[]; +} + +export interface FormattedEntry { + readonly text: string; + readonly dropped: boolean; +} + +function truncate(value: string, max: number): string { + return value.length <= max ? value : value.slice(0, max - 1) + ELLIPSIS; +} + +function serializeValue(raw: unknown): string { + if (typeof raw === 'string') return redactString(raw); + if (raw === undefined) return 'undefined'; + if (raw === null) return 'null'; + if ( + typeof raw === 'number' || + typeof raw === 'boolean' || + typeof raw === 'bigint' || + typeof raw === 'symbol' + ) { + return String(raw); + } + try { + const json = JSON.stringify(raw); + if (json !== undefined) return json; + } catch { + } + if (typeof raw === 'function') return raw.name === '' ? '[Function]' : `[Function: ${raw.name}]`; + return Object.prototype.toString.call(raw); +} + +function redactString(value: string): string { + let out = value; + for (const pattern of RAW_SECRET_PATTERNS) { + out = out.replace(pattern, `$1${REDACTED}`); + } + return out; +} + +function quote(value: string): string { + return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n')}"`; +} + +function formatPair(key: string, raw: unknown): string { + const limited = truncate(serializeValue(raw), CTX_VALUE_MAX_CHARS); + const renderedKey = SAFE_KEY_RE.test(key) ? key : quote(key); + const renderedVal = /[\s="\\]/.test(limited) || limited.length === 0 ? quote(limited) : limited; + return `${renderedKey}=${renderedVal}`; +} + +function clipBytes(text: string, maxBytes: number): string { + if (Buffer.byteLength(text, 'utf-8') <= maxBytes) return text; + let lo = 0; + let hi = text.length; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if ( + Buffer.byteLength(text.slice(0, mid), 'utf-8') <= + maxBytes - Buffer.byteLength(TRUNCATED_TAIL, 'utf-8') + ) { + lo = mid; + } else { + hi = mid - 1; + } + } + return text.slice(0, lo) + TRUNCATED_TAIL; +} + +function clipStack(stack: string): string { + if (Buffer.byteLength(stack, 'utf-8') <= STACK_MAX_BYTES) return stack; + return clipBytes(stack, STACK_MAX_BYTES); +} + +function indentStack(stack: string): string { + return stack + .split('\n') + .map((line, i) => (i === 0 ? ` ${line}` : ` ${line.trimStart()}`)) + .join('\n'); +} + +export function formatEntry(entry: LogEntry, options: FormatOptions = {}): FormattedEntry { + const ctx = entry.ctx ? redactCtx(entry.ctx) : undefined; + const omitContextKeys = new Set(options.omitContextKeys ?? []); + const msg = truncate(entry.msg, MSG_MAX_CHARS); + const pairs: string[] = []; + if (ctx) { + for (const [k, v] of Object.entries(ctx)) { + if (omitContextKeys.has(k)) continue; + if (v !== undefined) pairs.push(formatPair(k, v)); + } + } + + const time = new Date(entry.t).toISOString(); + const label = LEVEL_LABEL[entry.level]; + const rendered = pairs.length === 0 + ? `${time} ${label} ${msg}` + : `${time} ${label} ${msg} ${pairs.join(' ')}`; + + let head = Buffer.byteLength(rendered, 'utf-8') > ENTRY_MAX_BYTES + ? clipBytes(rendered, ENTRY_MAX_BYTES) + : rendered; + + if (options.ansi === true) { + head = `${ANSI_LEVEL[entry.level]}${head}${ANSI_RESET}`; + } + + if (entry.error?.stack) { + head = `${head}\n${indentStack(clipStack(redactString(entry.error.stack)))}`; + } else if (entry.error?.message) { + head = `${head}\n Error: ${redactString(entry.error.message)}`; + } + + return { text: head, dropped: false }; +} + +export function extractError(value: Error): LogEntryError { + return typeof value.stack === 'string' + ? { message: value.message, stack: value.stack } + : { message: value.message }; +} diff --git a/packages/agent-core-v2/src/_base/log/log.ts b/packages/agent-core-v2/src/_base/log/log.ts new file mode 100644 index 0000000000000000000000000000000000000000..58051c11a8b5d62c1a4d255696415d6f1ae1ed78 --- /dev/null +++ b/packages/agent-core-v2/src/_base/log/log.ts @@ -0,0 +1,59 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export type LogLevel = 'off' | 'error' | 'warn' | 'info' | 'debug'; + +export type LogContext = Record; + +export type LogPayload = unknown; + +export interface LogEntryError { + readonly message: string; + readonly stack?: string; +} + +export interface LogEntry { + readonly t: number; + readonly level: Exclude; + readonly msg: string; + readonly ctx?: LogContext; + readonly error?: LogEntryError; +} + +export interface ILogWriter { + write(entry: LogEntry): void; + flush?(): Promise; + close?(): Promise; + flushSync?(): void; +} + +export interface ILogger { + error(message: string, payload?: LogPayload): void; + warn(message: string, payload?: LogPayload): void; + info(message: string, payload?: LogPayload): void; + debug(message: string, payload?: LogPayload): void; + child(ctx: LogContext): ILogger; +} + +export interface ILogService extends ILogger { + readonly _serviceBrand: undefined; + + readonly level: LogLevel; + setLevel(level: LogLevel): void; + flush(): Promise; +} + +export const ILogService: ServiceIdentifier = + createDecorator('logService'); + +const LEVEL_ORDER: Record = { + off: 0, + error: 1, + warn: 2, + info: 3, + debug: 4, +}; + +export function levelEnabled(level: LogLevel, configured: LogLevel): boolean { + if (level === 'off' || configured === 'off') return false; + return LEVEL_ORDER[level] <= LEVEL_ORDER[configured]; +} diff --git a/packages/agent-core-v2/src/_base/log/logConfig.ts b/packages/agent-core-v2/src/_base/log/logConfig.ts new file mode 100644 index 0000000000000000000000000000000000000000..34ae349eea4ca6de04478ad6fde193e35eb31622 --- /dev/null +++ b/packages/agent-core-v2/src/_base/log/logConfig.ts @@ -0,0 +1,72 @@ +import { join } from 'pathe'; + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ScopeSeed } from '#/_base/di/scope'; + +import type { LogLevel } from './log'; + +export const DEFAULT_LOG_LEVEL: LogLevel = 'info'; +export const DEFAULT_GLOBAL_MAX_BYTES = 6 * 1024 * 1024; +export const DEFAULT_GLOBAL_FILES = 5; +export const DEFAULT_SESSION_MAX_BYTES = 5 * 1024 * 1024; +export const DEFAULT_SESSION_FILES = 3; + +export interface LoggingConfig { + readonly level: LogLevel; + readonly globalLogPath: string; + readonly globalMaxBytes: number; + readonly globalFiles: number; + readonly sessionMaxBytes: number; + readonly sessionFiles: number; +} + +export interface ILogOptions extends LoggingConfig {} + +export const ILogOptions: ServiceIdentifier = + createDecorator('logOptions'); + +export interface ResolveLoggingInput { + readonly homeDir: string; + readonly env: NodeJS.ProcessEnv; +} + +export function resolveGlobalLogPath(homeDir: string): string { + return join(homeDir, 'logs', 'kimi-code.log'); +} + +export function resolveSessionLogPath(sessionDir: string): string { + return join(sessionDir, 'logs', 'kimi-code.log'); +} + +export function resolveLoggingConfig(input: ResolveLoggingInput): LoggingConfig { + const env = input.env; + return { + level: parseLevel(env['KIMI_LOG_LEVEL']) ?? DEFAULT_LOG_LEVEL, + globalLogPath: resolveGlobalLogPath(input.homeDir), + globalMaxBytes: parsePositiveInt(env['KIMI_LOG_GLOBAL_MAX_BYTES']) ?? DEFAULT_GLOBAL_MAX_BYTES, + globalFiles: parsePositiveInt(env['KIMI_LOG_GLOBAL_FILES']) ?? DEFAULT_GLOBAL_FILES, + sessionMaxBytes: + parsePositiveInt(env['KIMI_LOG_SESSION_MAX_BYTES']) ?? DEFAULT_SESSION_MAX_BYTES, + sessionFiles: parsePositiveInt(env['KIMI_LOG_SESSION_FILES']) ?? DEFAULT_SESSION_FILES, + }; +} + +export function logSeed(config: LoggingConfig): ScopeSeed { + return [ + [ILogOptions as ServiceIdentifier, config satisfies ILogOptions], + ]; +} + +function parseLevel(value: string | undefined): LogLevel | undefined { + if (value === undefined) return undefined; + const v = value.toLowerCase().trim(); + if (v === 'off' || v === 'error' || v === 'warn' || v === 'info' || v === 'debug') return v; + return undefined; +} + +function parsePositiveInt(value: string | undefined): number | undefined { + if (value === undefined) return undefined; + const n = Number.parseInt(value, 10); + if (!Number.isFinite(n) || n <= 0) return undefined; + return n; +} diff --git a/packages/agent-core-v2/src/_base/log/logService.ts b/packages/agent-core-v2/src/_base/log/logService.ts new file mode 100644 index 0000000000000000000000000000000000000000..232a22075014edbf9cc869e8756170a275c15184 --- /dev/null +++ b/packages/agent-core-v2/src/_base/log/logService.ts @@ -0,0 +1,181 @@ +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; + +import { + type ILogger, + type ILogWriter, + type LogContext, + type LogEntry, + type LogEntryError, + type LogLevel, + type LogPayload, + ILogService, + levelEnabled, +} from './log'; +import { createFileLogWriter, type FileLogWriter } from './fileLog'; +import { ILogOptions } from './logConfig'; + +const pendingLogCloses = new Set>(); + +export function trackLogClose(close: Promise): void { + const tracked = close.then( + () => undefined, + () => undefined, + ); + pendingLogCloses.add(tracked); + void tracked.finally(() => pendingLogCloses.delete(tracked)); +} + +export async function drainLogCloses(): Promise { + while (pendingLogCloses.size > 0) { + await Promise.all(pendingLogCloses); + } +} + +interface ExtractedPayload { + readonly ctx?: LogContext; + readonly error?: LogEntryError; +} + +function errorEntry(error: Error): LogEntryError { + return { message: error.message, stack: error.stack }; +} + +function stringifyPayload(payload: Exclude): string { + if (typeof payload === 'string') return payload; + try { + const json = JSON.stringify(payload); + return json === undefined ? String(payload) : json; + } catch { + return String(payload); + } +} + +function extractPayload(payload: LogPayload): ExtractedPayload | undefined { + if (payload === undefined) return {}; + if (payload instanceof Error) return { error: errorEntry(payload) }; + if (typeof payload === 'object' && payload !== null) { + let entries: [string, unknown][]; + try { + entries = Object.entries(payload as Record); + } catch { + return undefined; + } + + let error: LogEntryError | undefined; + const ctx: LogContext = {}; + for (const [key, value] of entries) { + if (key === 'error' && value instanceof Error) { + error = errorEntry(value); + continue; + } + ctx[key] = value; + } + return { + ...(Object.keys(ctx).length > 0 ? { ctx } : {}), + ...(error !== undefined ? { error } : {}), + }; + } + + return { ctx: { reason: stringifyPayload(payload) } }; +} + +export interface LogLevelState { + level: LogLevel; +} + +export class BoundLogger extends Service implements ILogger { + constructor( + protected readonly writer: ILogWriter, + private readonly levelState: LogLevelState, + private readonly bound: LogContext = {}, + ) { + super(); + } + + child(ctx: LogContext): ILogger { + return new BoundLogger(this.writer, this.levelState, { ...this.bound, ...ctx }); + } + + error(message: string, payload?: LogPayload): void { + this.emit('error', message, payload); + } + warn(message: string, payload?: LogPayload): void { + this.emit('warn', message, payload); + } + info(message: string, payload?: LogPayload): void { + this.emit('info', message, payload); + } + debug(message: string, payload?: LogPayload): void { + this.emit('debug', message, payload); + } + + private emit( + level: Exclude, + message: string, + payload?: LogPayload, + ): void { + if (!levelEnabled(level, this.levelState.level)) return; + const extracted = extractPayload(payload); + if (extracted === undefined) return; + const payloadCtx = extracted.ctx; + const error = extracted.error; + const ctx = + payloadCtx !== undefined || Object.keys(this.bound).length > 0 + ? { ...payloadCtx, ...this.bound } + : undefined; + const entry: LogEntry = { + t: Date.now(), + level, + msg: message, + ...(ctx !== undefined ? { ctx } : {}), + ...(error !== undefined ? { error } : {}), + }; + this.writer.write(entry); + } +} + +export class AppLogService extends BoundLogger implements ILogService { + declare readonly _serviceBrand: undefined; + private readonly sink: FileLogWriter; + private readonly rootLevel: LogLevelState; + + constructor(@ILogOptions options: ILogOptions) { + const sink = createFileLogWriter({ + path: options.globalLogPath, + maxBytes: options.globalMaxBytes, + files: options.globalFiles, + }); + const rootLevel: LogLevelState = { level: options.level }; + super(sink, rootLevel); + this.sink = sink; + this.rootLevel = rootLevel; + } + + get level(): LogLevel { + return this.rootLevel.level; + } + + setLevel(level: LogLevel): void { + this.rootLevel.level = level; + } + + flush(): Promise { + return this.sink.flush(); + } + + override dispose(): void { + this.sink.flushSync(); + trackLogClose(this.sink.close()); + super.dispose(); + } +} + +registerScopedService( + LifecycleScope.App, + ILogService, + AppLogService, + ScopeActivation.OnScopeCreated, + 'log', +); diff --git a/packages/agent-core-v2/src/_base/state/stateRegistry.ts b/packages/agent-core-v2/src/_base/state/stateRegistry.ts new file mode 100644 index 0000000000000000000000000000000000000000..54fd1fbfb5d78fc2cc8ab4ba6047aabb5b96e89b --- /dev/null +++ b/packages/agent-core-v2/src/_base/state/stateRegistry.ts @@ -0,0 +1,163 @@ +import { Disposable, type IDisposable, toDisposable } from '../di/lifecycle'; +import { BugIndicatingError } from '../errors/errors'; +import { Emitter, type Event } from '../event'; + +export interface StateKey { + readonly name: string; + readonly initial: () => T; + readonly snapshotExcluded?: boolean; +} + +export interface StateChange { + readonly key: string; + readonly value: unknown; +} + +export interface StateInspection { + readonly scope: string; + readonly state: Record; + readonly parent?: StateInspection; +} + +export interface IStateRegistry { + contributeState(key: StateKey): IDisposable; + has(key: StateKey): boolean; + get(key: StateKey): T; + set(key: StateKey, value: T): void; + onDidChange(key: StateKey): Event; + readonly onDidChangeAny: Event; + entries(): readonly [string, unknown][]; + snapshot(): Record; + inspect(): StateInspection; +} + +export class StateRegistry extends Disposable implements IStateRegistry { + private readonly values = new Map(); + private readonly registrations = new Map(); + private readonly excludedFromSnapshot = new Set(); + private readonly keyEmitters = new Map>(); + private readonly anyEmitter = this._register(new Emitter()); + readonly onDidChangeAny: Event = this.anyEmitter.event; + + protected readonly inspectScope: string = 'unknown'; + protected inspectParent?: IStateRegistry; + + contributeState(key: StateKey): IDisposable { + const replayable = (key as StateKey & { readonly replayable?: unknown }).replayable; + if (typeof replayable === 'object' && replayable !== null) { + throw new BugIndicatingError( + `replayable state key '${key.name}' must be contributed to the Agent-scope state service`, + ); + } + return this.contributeKey(key); + } + + protected contributeKey(key: StateKey): IDisposable { + if (this.values.has(key.name)) { + throw new BugIndicatingError(`state key '${key.name}' is already registered`); + } + const registration = {}; + this.registrations.set(key.name, registration); + this.values.set(key.name, key.initial()); + if (key.snapshotExcluded === true) { + this.excludedFromSnapshot.add(key.name); + } + return toDisposable(() => { + if (this.registrations.get(key.name) !== registration) return; + this.registrations.delete(key.name); + this.values.delete(key.name); + this.excludedFromSnapshot.delete(key.name); + this.keyEmitters.get(key.name)?.dispose(); + this.keyEmitters.delete(key.name); + }); + } + + has(key: StateKey): boolean { + return this.values.has(key.name); + } + + get(key: StateKey): 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(key: StateKey, 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(key: StateKey): Event { + let emitter = this.keyEmitters.get(key.name); + if (emitter === undefined) { + emitter = this._register(new Emitter()); + this.keyEmitters.set(key.name, emitter); + } + return emitter.event as Event; + } + + entries(): readonly [string, unknown][] { + return Array.from(this.values.entries()); + } + + snapshot(): Record { + const out: Record = {}; + for (const [key, value] of this.values) { + if (this.excludedFromSnapshot.has(key)) continue; + out[key] = toJsonSafe(value, new WeakSet()); + } + return out; + } + + inspect(): StateInspection { + return { + scope: this.inspectScope, + state: this.snapshot(), + parent: this.inspectParent?.inspect(), + }; + } +} + +function toJsonSafe(value: unknown, seen: WeakSet): 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)); + } + 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 = {}; + for (const [key, item] of Object.entries(value)) { + if (typeof item === 'function') continue; + out[key] = toJsonSafe(item, seen); + } + return out; + } finally { + seen.delete(value); + } +} diff --git a/packages/agent-core-v2/src/_base/text/encoding.ts b/packages/agent-core-v2/src/_base/text/encoding.ts new file mode 100644 index 0000000000000000000000000000000000000000..154e5bf17a10ab90ff4a5de7959064d5329cba5c --- /dev/null +++ b/packages/agent-core-v2/src/_base/text/encoding.ts @@ -0,0 +1,113 @@ +export type UtfTextEncoding = 'utf-8' | 'utf-16le' | 'utf-16be'; + +export interface TextClassification { + readonly isBinary: boolean; + readonly encoding: UtfTextEncoding; +} + +export const FS_BINARY_NONPRINTABLE_FRACTION = 0.3; + +export interface TextEncodingDetection { + readonly encoding: UtfTextEncoding; + readonly seemsBinary: boolean; +} + +export const ENCODING_DETECTION_SAMPLE_BYTES = 512; + +const MIN_ZERO_BYTES_FOR_UTF16 = 2; + +const UTF16BE_BOM = [0xfe, 0xff] as const; +const UTF16LE_BOM = [0xff, 0xfe] as const; +const UTF8_BOM = [0xef, 0xbb, 0xbf] as const; + +function sniffTextEncoding(sample: Uint8Array): TextEncodingDetection { + if (sample.length >= 2) { + const b0 = sample[0]!; + const b1 = sample[1]!; + if (b0 === UTF16BE_BOM[0] && b1 === UTF16BE_BOM[1]) { + return { encoding: 'utf-16be', seemsBinary: false }; + } + if (b0 === UTF16LE_BOM[0] && b1 === UTF16LE_BOM[1]) { + return { encoding: 'utf-16le', seemsBinary: false }; + } + if (sample.length >= 3 && b0 === UTF8_BOM[0] && b1 === UTF8_BOM[1] && sample[2] === UTF8_BOM[2]) { + return { encoding: 'utf-8', seemsBinary: false }; + } + } + + let zerosAtOdd = 0; + let zerosAtEven = 0; + const limit = Math.min(sample.length, ENCODING_DETECTION_SAMPLE_BYTES); + for (let i = 0; i < limit; i++) { + if (sample[i] !== 0) continue; + if (i % 2 === 1) zerosAtOdd++; + else zerosAtEven++; + } + + if (zerosAtOdd === 0 && zerosAtEven === 0) { + return { encoding: 'utf-8', seemsBinary: false }; + } + if (zerosAtEven === 0 && zerosAtOdd >= MIN_ZERO_BYTES_FOR_UTF16) { + return { encoding: 'utf-16le', seemsBinary: false }; + } + if (zerosAtOdd === 0 && zerosAtEven >= MIN_ZERO_BYTES_FOR_UTF16) { + return { encoding: 'utf-16be', seemsBinary: false }; + } + return { encoding: 'utf-8', seemsBinary: true }; +} + +export function classifyTextSample(sample: Uint8Array): TextClassification { + const sniffed = sniffTextEncoding(sample); + if (sniffed.seemsBinary || sniffed.encoding !== 'utf-8') { + return { isBinary: sniffed.seemsBinary, encoding: sniffed.encoding }; + } + if (sample.includes(0)) { + return { isBinary: true, encoding: 'utf-8' }; + } + let end = sample.length; + for (let i = Math.max(0, sample.length - 3); i < sample.length; i++) { + const b = sample[i]!; + const expected = + b >= 0xc2 && b <= 0xdf ? 2 : b >= 0xe0 && b <= 0xef ? 3 : b >= 0xf0 && b <= 0xf4 ? 4 : 0; + if (expected === 0 || i + expected <= sample.length) continue; + let validPrefix = true; + for (let j = i + 1; j < sample.length; j++) { + const cb = sample[j]!; + if (cb < 0x80 || cb > 0xbf) { + validPrefix = false; + break; + } + } + if (validPrefix) { + end = i; + break; + } + } + let text: string; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(sample.subarray(0, end)); + } catch { + return { isBinary: true, encoding: 'utf-8' }; + } + let nonPrintable = 0; + let total = 0; + for (const ch of text) { + const cp = ch.codePointAt(0)!; + total++; + if (cp === 9 || cp === 10 || cp === 13) continue; + if (cp < 32 || (cp >= 0x7f && cp <= 0x9f)) nonPrintable++; + } + if (total > 0 && nonPrintable / total > FS_BINARY_NONPRINTABLE_FRACTION) { + return { isBinary: true, encoding: 'utf-8' }; + } + return { isBinary: false, encoding: 'utf-8' }; +} + +export function detectTextEncoding(sample: Uint8Array): TextEncodingDetection { + const classification = classifyTextSample(sample); + return { encoding: classification.encoding, seemsBinary: classification.isBinary }; +} + +export function decodeUtfText(bytes: Uint8Array, encoding: UtfTextEncoding): string { + return new TextDecoder(encoding, { fatal: false }).decode(bytes); +} diff --git a/packages/agent-core-v2/src/_base/text/frontmatter.ts b/packages/agent-core-v2/src/_base/text/frontmatter.ts new file mode 100644 index 0000000000000000000000000000000000000000..601a0254c67b32d6e0841c360165bc66aaf915eb --- /dev/null +++ b/packages/agent-core-v2/src/_base/text/frontmatter.ts @@ -0,0 +1,43 @@ +import { load as loadYaml } from 'js-yaml'; + +export class FrontmatterError extends Error { + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'FrontmatterError'; + if (cause !== undefined) { + Object.defineProperty(this, 'cause', { value: cause, configurable: true }); + } + } +} + +export interface ParsedFrontmatter { + readonly data: unknown; + readonly body: string; +} + +const FENCE = '---'; + +export function parseFrontmatter(text: string): ParsedFrontmatter { + const lines = text.split(/\r?\n/); + if (lines[0]?.trim() !== FENCE) { + return { data: null, body: text }; + } + + const close = lines.findIndex((line, index) => index > 0 && line.trim() === FENCE); + if (close === -1) { + throw new FrontmatterError('Missing closing frontmatter fence'); + } + + const yamlText = lines.slice(1, close).join('\n').trim(); + const body = lines.slice(close + 1).join('\n'); + if (yamlText === '') { + return { data: {}, body }; + } + + try { + return { data: loadYaml(yamlText) ?? {}, body }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new FrontmatterError(message, error); + } +} diff --git a/packages/agent-core-v2/src/_base/text/line-endings.ts b/packages/agent-core-v2/src/_base/text/line-endings.ts new file mode 100644 index 0000000000000000000000000000000000000000..09725d37740d078740f2e1f8ab280254fd4c748a --- /dev/null +++ b/packages/agent-core-v2/src/_base/text/line-endings.ts @@ -0,0 +1,67 @@ +export type LineEndingStyle = 'lf' | 'crlf' | 'mixed'; + +export interface ModelTextView { + text: string; + lineEndingStyle: LineEndingStyle; +} + +export function detectLineEndingStyle(text: string): LineEndingStyle { + let hasCrLf = false; + let hasLf = false; + let hasLoneCr = false; + + for (let i = 0; i < text.length; i++) { + const code = text.codePointAt(i); + if (code === 13) { + if (text.codePointAt(i + 1) === 10) { + hasCrLf = true; + i++; + } else { + hasLoneCr = true; + } + } else if (code === 10) { + hasLf = true; + } + } + + if (hasLoneCr || (hasCrLf && hasLf)) return 'mixed'; + if (hasCrLf) return 'crlf'; + return 'lf'; +} + +export function toModelTextView(raw: string): ModelTextView { + const lineEndingStyle = detectLineEndingStyle(raw); + if (lineEndingStyle !== 'crlf') { + return { text: raw, lineEndingStyle }; + } + + return { + text: raw.replaceAll('\r\n', '\n'), + lineEndingStyle, + }; +} + +export function materializeModelText(text: string, lineEndingStyle: LineEndingStyle): string { + if (lineEndingStyle !== 'crlf') return text; + return text.replaceAll('\r\n', '\n').replaceAll('\n', '\r\n'); +} + +export function makeCarriageReturnsVisible(text: string): string { + return text.replaceAll('\r', '\\r'); +} + +export function splitLinesKeepingTerminator(text: string): string[] { + if (text.length === 0) return []; + const lines: string[] = []; + let start = 0; + for (let i = 0; i < text.length; i += 1) { + if (text.codePointAt(i) === 0x0a) { + lines.push(text.slice(start, i + 1)); + start = i + 1; + } + } + if (start < text.length) { + lines.push(text.slice(start)); + } + return lines; +} diff --git a/packages/agent-core-v2/src/_base/utils/abort.ts b/packages/agent-core-v2/src/_base/utils/abort.ts new file mode 100644 index 0000000000000000000000000000000000000000..7662966b21ddde0a6e0f283fc5ddb78cd3405c18 --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/abort.ts @@ -0,0 +1,93 @@ +export function abortError(message = 'Aborted'): Error { + const error = new Error(message); + error.name = 'AbortError'; + return error; +} + +export function isAbortError(error: unknown): error is Error { + return error instanceof Error && error.name === 'AbortError'; +} + +export class UserCancellationError extends Error { + readonly userCancelled = true; + + constructor() { + super('Aborted by the user'); + this.name = 'AbortError'; + } +} + +export function userCancellationReason(): UserCancellationError { + return new UserCancellationError(); +} + +export function isUserCancellation(value: unknown): value is UserCancellationError { + return value instanceof UserCancellationError; +} + +export function abortable(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(abortReason(signal)); + return new Promise((resolve, reject) => { + const onAbort = () => { + reject(abortReason(signal)); + }; + signal.addEventListener('abort', onAbort, { once: true }); + promise.then(resolve, reject).finally(() => { + signal.removeEventListener('abort', onAbort); + }); + }); +} + +export function linkAbortSignal(source: AbortSignal, target: AbortController): () => void { + const onAbort = () => { + target.abort(source.reason); + }; + if (source.aborted) { + onAbort(); + return () => {}; + } + source.addEventListener('abort', onAbort, { once: true }); + return () => { + source.removeEventListener('abort', onAbort); + }; +} + +function abortReason(signal: AbortSignal): Error { + if (signal.reason instanceof Error && !isDefaultAbortReason(signal.reason)) { + return signal.reason; + } + return abortError(); +} + +function isDefaultAbortReason(reason: Error): boolean { + return reason.name === 'AbortError' && reason.message === 'This operation was aborted'; +} + +export interface DeadlineAbortSignal { + readonly signal: AbortSignal; + readonly timedOut: () => boolean; + readonly clear: () => void; +} + +export function createDeadlineAbortSignal( + source: AbortSignal, + timeoutMs: number, +): DeadlineAbortSignal { + const controller = new AbortController(); + const unlinkAbortSignal = linkAbortSignal(source, controller); + let didTimeout = false; + let timeout: ReturnType | undefined = setTimeout(() => { + didTimeout = true; + controller.abort(abortError()); + }, timeoutMs); + + return { + signal: controller.signal, + timedOut: () => didTimeout, + clear: () => { + if (timeout !== undefined) clearTimeout(timeout); + timeout = undefined; + unlinkAbortSignal(); + }, + }; +} diff --git a/packages/agent-core-v2/src/_base/utils/canonical-args.ts b/packages/agent-core-v2/src/_base/utils/canonical-args.ts new file mode 100644 index 0000000000000000000000000000000000000000..feca131c1c86be7cb5cf923d45c9038f94430229 --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/canonical-args.ts @@ -0,0 +1,24 @@ +export function canonicalTelemetryArgs(args: unknown): string { + const json = JSON.stringify(sortJsonValue(args)); + return json ?? String(args); +} + +function sortJsonValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortJsonValue); + } + if (!isPlainRecord(value)) { + return value; + } + const out: Record = {}; + for (const key of Object.keys(value).toSorted()) { + out[key] = sortJsonValue(value[key]); + } + return out; +} + +export function isPlainRecord(value: unknown): value is Record { + if (value === null || typeof value !== 'object') return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} diff --git a/packages/agent-core-v2/src/_base/utils/env.ts b/packages/agent-core-v2/src/_base/utils/env.ts new file mode 100644 index 0000000000000000000000000000000000000000..12a62fc746494f329906c2c8280c693d82c1727b --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/env.ts @@ -0,0 +1,10 @@ +const TRUE_BOOLEAN_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']); +const FALSE_BOOLEAN_ENV_VALUES = new Set(['0', 'false', 'no', 'off']); + +export function parseBooleanEnv(value: string | undefined): boolean | undefined { + const normalized = value?.trim().toLowerCase(); + if (normalized === undefined || normalized.length === 0) return undefined; + if (TRUE_BOOLEAN_ENV_VALUES.has(normalized)) return true; + if (FALSE_BOOLEAN_ENV_VALUES.has(normalized)) return false; + return undefined; +} diff --git a/packages/agent-core-v2/src/_base/utils/fileMeta.ts b/packages/agent-core-v2/src/_base/utils/fileMeta.ts new file mode 100644 index 0000000000000000000000000000000000000000..4d3c9fcab88bdb0dd882e8d1b116598d934653c1 --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/fileMeta.ts @@ -0,0 +1,113 @@ +import { extname } from 'node:path'; + +import { classifyTextSample } from '#/_base/text/encoding'; + +export { FS_BINARY_NONPRINTABLE_FRACTION } from '#/_base/text/encoding'; + +export const FS_BINARY_SAMPLE_BYTES = 4096; + +export interface FileMetaStat { + readonly size: number; + readonly mtimeMs?: number; + readonly ino?: number; +} + +export function detectBinary(buf: Uint8Array): boolean { + return classifyTextSample(buf).isBinary; +} + +export function countLines(text: string): number { + if (text.length === 0) return 0; + let n = 1; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) === 10) n++; + } + if (text.charCodeAt(text.length - 1) === 10) n--; + return Math.max(0, n); +} + +export function buildEtag(st: FileMetaStat): string { + const mtime = Math.floor(st.mtimeMs ?? 0); + const ino = st.ino ?? 0; + return [mtime.toString(36), st.size.toString(36), ino.toString(36)].join('-'); +} + +const EXT_TO_MIME: Readonly> = { + '.ts': 'text/typescript', + '.tsx': 'text/typescript', + '.js': 'text/javascript', + '.jsx': 'text/javascript', + '.mjs': 'text/javascript', + '.cjs': 'text/javascript', + '.json': 'application/json', + '.md': 'text/markdown', + '.html': 'text/html', + '.css': 'text/css', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.pdf': 'application/pdf', + '.yaml': 'text/yaml', + '.yml': 'text/yaml', + '.toml': 'application/toml', + '.sh': 'text/x-shellscript', + '.py': 'text/x-python', + '.rs': 'text/rust', + '.go': 'text/x-go', +}; + +export function guessMime(path: string, isBinary: boolean): string { + const ext = extname(path).toLowerCase(); + const mapped = EXT_TO_MIME[ext]; + if (mapped !== undefined) return mapped; + return isBinary ? 'application/octet-stream' : 'text/plain'; +} + +const APPLICATION_TEXT_ALIASES: Readonly> = { + 'application/javascript': 'text/javascript', + 'application/x-javascript': 'text/javascript', + 'application/ecmascript': 'text/javascript', + 'application/yaml': 'text/yaml', + 'application/x-yaml': 'text/yaml', + 'application/sql': 'text/plain', + 'application/graphql': 'text/plain', + 'application/x-www-form-urlencoded': 'text/plain', +}; + +export function textExtensionForMime(mimeType: string): string | undefined { + const mime = mimeType.split(';')[0]!.trim().toLowerCase(); + if (mime === 'application/json' || mime.endsWith('+json')) return '.json'; + if (mime === 'application/xml' || mime.endsWith('+xml')) return '.xml'; + if (mime.endsWith('+yaml')) return '.yaml'; + if (mime === 'application/toml') return '.toml'; + if (mime === 'text/csv') return '.csv'; + const textMime = APPLICATION_TEXT_ALIASES[mime] ?? mime; + if (!textMime.startsWith('text/')) return undefined; + return Object.entries(EXT_TO_MIME).find(([, value]) => value === textMime)?.[0] ?? '.txt'; +} + +const EXT_TO_LANGUAGE: Readonly> = { + '.ts': 'typescript', + '.tsx': 'typescriptreact', + '.js': 'javascript', + '.jsx': 'javascriptreact', + '.mjs': 'javascript', + '.cjs': 'javascript', + '.json': 'json', + '.md': 'markdown', + '.html': 'html', + '.css': 'css', + '.yaml': 'yaml', + '.yml': 'yaml', + '.toml': 'toml', + '.sh': 'shellscript', + '.py': 'python', + '.rs': 'rust', + '.go': 'go', +}; + +export function guessLanguageId(path: string): string | undefined { + return EXT_TO_LANGUAGE[extname(path).toLowerCase()]; +} diff --git a/packages/agent-core-v2/src/_base/utils/fs.ts b/packages/agent-core-v2/src/_base/utils/fs.ts new file mode 100644 index 0000000000000000000000000000000000000000..da9bacd1a33e8a41c820afbf56d9215a95a179f1 --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/fs.ts @@ -0,0 +1,168 @@ +import { randomBytes } from 'node:crypto'; +import { closeSync, fsyncSync, openSync } from 'node:fs'; +import * as nodeFs from 'node:fs'; +import { open, rename, unlink } from 'node:fs/promises'; +import { dirname } from 'pathe'; + +export async function syncDir(dirPath: string): Promise { + if (process.platform === 'win32') return; + const dirFh = await open(dirPath, 'r'); + try { + await dirFh.sync(); + } finally { + await dirFh.close(); + } +} + +export function syncDirSync(dirPath: string): void { + if (process.platform === 'win32') return; + const fd = openSync(dirPath, 'r'); + try { + fsyncSync(fd); + } finally { + closeSync(fd); + } +} + +export async function writeFileAtomicDurable( + filePath: string, + content: string | Uint8Array, +): Promise { + const tmpPath = filePath + '.tmp'; + let renamed = false; + try { + const fh = await open(tmpPath, 'w'); + try { + await fh.writeFile(content); + await fh.sync(); + } finally { + await fh.close(); + } + if (process.platform === 'win32') { + try { + await unlink(filePath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') throw error; + } + } + await rename(tmpPath, filePath); + renamed = true; + await syncDir(dirname(filePath)); + } finally { + if (!renamed) { + try { + await unlink(tmpPath); + } catch { + } + } + } +} + +function syncFd(fd: number): Promise { + return new Promise((resolve, reject) => { + nodeFs.fsync(fd, (err) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); +} + +export async function atomicWrite( + filePath: string, + content: string | Uint8Array, + _syncOverride?: (fd: number) => Promise, + mode?: number, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + const hex = randomBytes(4).toString('hex'); + const tmpPath = `${filePath}.tmp.${process.pid}.${hex}`; + let renamed = false; + try { + const fh = await open(tmpPath, 'w', mode); + try { + signal?.throwIfAborted(); + await fh.writeFile(content); + signal?.throwIfAborted(); + await (_syncOverride ?? syncFd)(fh.fd); + } finally { + await fh.close(); + } + if (process.platform === 'win32') { + try { + await unlink(filePath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') throw error; + } + } + signal?.throwIfAborted(); + await rename(tmpPath, filePath); + renamed = true; + } finally { + if (!renamed) { + try { + await unlink(tmpPath); + } catch { + } + } + } +} + +export async function atomicWriteStream( + filePath: string, + source: AsyncIterable, + mode?: number, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + const hex = randomBytes(4).toString('hex'); + const tmpPath = `${filePath}.tmp.${process.pid}.${hex}`; + let renamed = false; + const destroyable = source as AsyncIterable & { + destroy?(error?: Error): void; + }; + const onAbort = (): void => { + const reason = signal?.reason instanceof Error ? signal.reason : undefined; + destroyable.destroy?.(reason); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + try { + const fh = await open(tmpPath, 'w', mode); + try { + for await (const chunk of source) { + signal?.throwIfAborted(); + if (chunk.byteLength > 0) { + await fh.writeFile(chunk); + } + } + signal?.throwIfAborted(); + await fh.sync(); + } finally { + await fh.close(); + } + if (process.platform === 'win32') { + try { + await unlink(filePath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') throw error; + } + } + signal?.throwIfAborted(); + await rename(tmpPath, filePath); + renamed = true; + } finally { + signal?.removeEventListener('abort', onAbort); + if (!renamed) { + try { + await unlink(tmpPath); + } catch { + } + } + } +} diff --git a/packages/agent-core-v2/src/_base/utils/hero-slug.ts b/packages/agent-core-v2/src/_base/utils/hero-slug.ts new file mode 100644 index 0000000000000000000000000000000000000000..a8099bccf994118fa6426a666019a738180b3c77 --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/hero-slug.ts @@ -0,0 +1,263 @@ +import { randomInt } from 'node:crypto'; + +export const HERO_NAMES = [ + 'iron-man', + 'spider-man', + 'captain-america', + 'thor', + 'hulk', + 'black-widow', + 'hawkeye', + 'black-panther', + 'doctor-strange', + 'scarlet-witch', + 'vision', + 'falcon', + 'war-machine', + 'ant-man', + 'wasp', + 'captain-marvel', + 'gamora', + 'star-lord', + 'groot', + 'rocket', + 'drax', + 'mantis', + 'nebula', + 'shang-chi', + 'moon-knight', + 'ms-marvel', + 'she-hulk', + 'echo', + 'wolverine', + 'cyclops', + 'storm', + 'jean-grey', + 'rogue', + 'beast', + 'nightcrawler', + 'colossus', + 'shadowcat', + 'jubilee', + 'cable', + 'deadpool', + 'bishop', + 'magik', + 'iceman', + 'archangel', + 'psylocke', + 'dazzler', + 'forge', + 'havok', + 'polaris', + 'emma-frost', + 'namor', + 'silver-surfer', + 'adam-warlock', + 'nova', + 'quasar', + 'sentry', + 'blue-marvel', + 'spectrum', + 'squirrel-girl', + 'cloak', + 'dagger', + 'punisher', + 'elektra', + 'luke-cage', + 'iron-fist', + 'jessica-jones', + 'daredevil', + 'blade', + 'ghost-rider', + 'morbius', + 'venom', + 'carnage', + 'silk', + 'spider-gwen', + 'miles-morales', + 'america-chavez', + 'kate-bishop', + 'yelena-belova', + 'white-tiger', + 'moon-girl', + 'devil-dinosaur', + 'amadeus-cho', + 'riri-williams', + 'kamala-khan', + 'sam-alexander', + 'nova-prime', + 'medusa', + 'black-bolt', + 'crystal', + 'karnak', + 'gorgon', + 'lockjaw', + 'quake', + 'mockingbird', + 'bobbi-morse', + 'maria-hill', + 'nick-fury', + 'phil-coulson', + 'winter-soldier', + 'us-agent', + 'patriot', + 'speed', + 'wiccan', + 'hulkling', + 'stature', + 'yellowjacket', + 'tigra', + 'hellcat', + 'valkyrie', + 'sif', + 'beta-ray-bill', + 'hercules', + 'wonder-man', + 'taskmaster', + 'domino', + 'cannonball', + 'sunspot', + 'wolfsbane', + 'warpath', + 'multiple-man', + 'banshee', + 'siryn', + 'monet', + 'rictor', + 'shatterstar', + 'longshot', + 'daken', + 'x-23', + 'fantomex', + 'batman', + 'superman', + 'wonder-woman', + 'flash', + 'aquaman', + 'green-lantern', + 'martian-manhunter', + 'cyborg', + 'hawkgirl', + 'green-arrow', + 'black-canary', + 'zatanna', + 'constantine', + 'shazam', + 'blue-beetle', + 'booster-gold', + 'firestorm', + 'atom', + 'hawkman', + 'plastic-man', + 'red-tornado', + 'starfire', + 'raven', + 'beast-boy', + 'robin', + 'nightwing', + 'batgirl', + 'batwoman', + 'red-hood', + 'signal', + 'orphan', + 'spoiler', + 'catwoman', + 'huntress', + 'supergirl', + 'superboy', + 'power-girl', + 'steel', + 'stargirl', + 'wildcat', + 'doctor-fate', + 'mister-terrific', + 'hourman', + 'sandman', + 'spectre', + 'phantom-stranger', + 'swamp-thing', + 'animal-man', + 'deadman', + 'vixen', + 'black-lightning', + 'static', + 'icon', + 'rocket-dc', + 'captain-atom', + 'fire', + 'ice', + 'elongated-man', + 'metamorpho', + 'black-hawk', + 'crimson-avenger', + 'doctor-mid-nite', + 'jakeem-thunder', + 'mister-miracle', + 'big-barda', + 'orion', + 'lightray', + 'forager', + 'killer-frost', + 'jessica-cruz', + 'simon-baz', + 'john-stewart', + 'guy-gardner', + 'kyle-rayner', + 'hal-jordan', + 'wally-west', + 'barry-allen', + 'jay-garrick', + 'impulse', + 'kid-flash', + 'donna-troy', + 'tempest', + 'aqualad', + 'miss-martian', + 'terra', + 'jericho', + 'ravager', + 'red-star', + 'pantha', + 'argent', + 'damage', + 'jade', + 'obsidian', + 'cyclone', + 'atom-smasher', + 'maxima', + 'starman', + 'liberty-belle', + 'dove', + 'hawk', + 'blue-devil', + 'creeper', + 'ragman', + 'thunder', +] as const satisfies readonly [string, ...string[]]; + +const MAX_ATTEMPTS = 20; + +function pickHero(): string { + return HERO_NAMES[randomInt(HERO_NAMES.length)]!; +} + +function assembleSlug(): string { + return `${pickHero()}-${pickHero()}-${pickHero()}`; +} + +export function generateHeroSlug(id: string, existing: Set): string { + let slug = ''; + let collided = true; + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + slug = assembleSlug(); + if (!existing.has(slug)) { + collided = false; + break; + } + } + if (collided) { + slug = `${slug}-${id.slice(0, 8)}`; + } + return slug; +} diff --git a/packages/agent-core-v2/src/_base/utils/isoDateTime.ts b/packages/agent-core-v2/src/_base/utils/isoDateTime.ts new file mode 100644 index 0000000000000000000000000000000000000000..951f0c5084c26eaa4e31eafde1588ba1570c448f --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/isoDateTime.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +const ISO_8601_REGEX = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}(?::?\d{2})?)$/; + +export const isoDateTimeSchema = z + .string() + .refine((value) => ISO_8601_REGEX.test(value), { + message: 'must be an ISO 8601 datetime string', + }) + .transform((value, ctx) => { + const ms = Date.parse(value); + if (Number.isNaN(ms)) { + ctx.addIssue({ + code: 'custom', + message: 'invalid ISO 8601 datetime', + }); + return z.NEVER; + } + return new Date(ms).toISOString(); + }); diff --git a/packages/agent-core-v2/src/_base/utils/paths.ts b/packages/agent-core-v2/src/_base/utils/paths.ts new file mode 100644 index 0000000000000000000000000000000000000000..bf035216f0ae07b809e32fb8d63f117d4bd22347 --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/paths.ts @@ -0,0 +1,136 @@ +import nodePath from 'node:path'; + +import { isAbsolute, normalize, resolve } from 'pathe'; + +import { workspaceRootKey } from './workdir-slug'; + +function normalizeSlashes(p: string): string { + return p.replaceAll('\\', '/'); +} + +export function isWindowsAbsolutePath(value: string): boolean { + return /^[A-Za-z]:[\\/]/.test(value) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+/.test(value); +} + +export function resolvePath(base: string, value: string): string { + if (isWindowsAbsolutePath(base)) { + return nodePath.win32.resolve(base, value).replaceAll('\\', '/'); + } + if (isWindowsAbsolutePath(value)) { + return nodePath.win32.resolve(value).replaceAll('\\', '/'); + } + return isAbsolute(value) ? normalize(value) : resolve(base, value); +} + +export function canonicalWorkspaceRoot(cwd: string): string { + const resolved = isWindowsAbsolutePath(cwd) + ? nodePath.win32.resolve(cwd).replaceAll('\\', '/') + : resolve(cwd); + return workspaceRootKey(resolved) || resolved; +} + +export interface UpwardRootPathApi { + resolve(dir: string): string; + dirname(dir: string): string; + join(...segments: string[]): string; +} + +export async function findUpwardRoot( + workDir: string, + markerName: string, + hasMarker: (markerPath: string) => Promise, + pathApi: UpwardRootPathApi = nodePath, +): Promise { + const start = pathApi.resolve(workDir); + let current = start; + while (true) { + if (await hasMarker(pathApi.join(current, markerName))) return normalizeSlashes(current); + const parent = pathApi.dirname(current); + if (parent === current) return normalizeSlashes(start); + current = parent; + } +} + +export interface SubtreeWatchFilterOptions { + readonly maxDepth?: number; + readonly skipEntry?: (entryName: string) => boolean; + readonly keepEntryFile?: string; + readonly scannedDirectories?: readonly string[]; +} + +export function subtreeWatchFilter( + root: string, + candidates: readonly string[], + options?: SubtreeWatchFilterOptions, +): (path: string) => boolean { + const normRoot = normalizeSlashes(root); + const normCandidates = candidates.map(normalizeSlashes); + const normScannedDirectories = + options?.scannedDirectories === undefined + ? undefined + : new Set([...normCandidates, ...options.scannedDirectories.map(normalizeSlashes)]); + return (p: string): boolean => { + const norm = normalizeSlashes(p); + if (norm === normRoot) return false; + for (const candidate of normCandidates) { + if (norm === candidate) return false; + if (norm.startsWith(`${candidate}/`)) { + return isPrunedBelowCandidate( + norm, + norm.slice(candidate.length + 1), + options, + normScannedDirectories, + ); + } + if (candidate.startsWith(`${norm}/`)) return false; + } + return true; + }; +} + +function isPrunedBelowCandidate( + normPath: string, + rel: string, + options: SubtreeWatchFilterOptions | undefined, + scannedDirectories: ReadonlySet | undefined, +): boolean { + if (options === undefined) return false; + const segments = rel.split('/'); + if (options.maxDepth !== undefined && segments.length > options.maxDepth) return true; + if (options.skipEntry !== undefined) { + const excludedAt = segments.findIndex(options.skipEntry); + if (excludedAt !== -1) { + if (segments.length <= excludedAt + 1) return false; + return !( + options.keepEntryFile !== undefined && + segments.length === excludedAt + 2 && + segments.at(-1) === options.keepEntryFile + ); + } + } + if (scannedDirectories !== undefined) { + return !isScannerVisiblePath(normPath, scannedDirectories, options.keepEntryFile); + } + return false; +} + +function isScannerVisiblePath( + normPath: string, + scannedDirectories: ReadonlySet, + keepEntryFile: string | undefined, +): boolean { + if (scannedDirectories.has(normPath)) return true; + const separatorAt = normPath.lastIndexOf('/'); + if (separatorAt === -1) return false; + const parent = normPath.slice(0, separatorAt); + if (scannedDirectories.has(parent)) return true; + if ( + keepEntryFile === undefined || + normPath.slice(separatorAt + 1) !== keepEntryFile + ) { + return false; + } + const parentSeparatorAt = parent.lastIndexOf('/'); + if (parentSeparatorAt === -1) return false; + return scannedDirectories.has(parent.slice(0, parentSeparatorAt)); +} diff --git a/packages/agent-core-v2/src/_base/utils/promise.ts b/packages/agent-core-v2/src/_base/utils/promise.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a6e610992f7cae67bb0feefbc55f49bb0b4b1cd --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/promise.ts @@ -0,0 +1,31 @@ +import { setClampedTimeout } from './timer'; + +const NEVER = new Promise(() => {}); + +export type TimeoutOutcomePromise = Promise & { + clear(): void; +}; + +export function timeoutOutcome( + timeoutMs: number | undefined, + outcome: Outcome, +): TimeoutOutcomePromise { + let timeout: ReturnType | undefined; + const promise: Promise = + timeoutMs === undefined || timeoutMs <= 0 + ? NEVER + : new Promise((resolve) => { + timeout = setClampedTimeout(() => { + timeout = undefined; + resolve(outcome); + }, timeoutMs); + }); + + return Object.assign(promise, { + clear() { + if (timeout === undefined) return; + clearTimeout(timeout); + timeout = undefined; + }, + }); +} diff --git a/packages/agent-core-v2/src/_base/utils/proxy.ts b/packages/agent-core-v2/src/_base/utils/proxy.ts new file mode 100644 index 0000000000000000000000000000000000000000..12570257a13405b2c62bd787b57986487d0ef850 --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/proxy.ts @@ -0,0 +1,259 @@ +import { + Agent, + buildConnector, + type Dispatcher, + EnvHttpProxyAgent, + setGlobalDispatcher as undiciSetGlobalDispatcher, +} from 'undici'; +import { SocksClient } from 'socks'; + +type Env = Readonly>; + +export interface SocksProxyConfig { + readonly type: 4 | 5; + readonly host: string; + readonly port: number; + readonly userId?: string; + readonly password?: string; +} + +const LOOPBACK_NO_PROXY = ['localhost', '127.0.0.1', '::1', '[::1]'] as const; + +const SOCKS_SCHEMES = new Set(['socks', 'socks4', 'socks4a', 'socks5', 'socks5h']); + +function schemeOf(value: string): string | undefined { + return /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1]?.toLowerCase(); +} + +function firstNonBlank(env: Env, keys: readonly string[]): string | undefined { + for (const key of keys) { + const value = env[key]?.trim(); + if (value !== undefined && value.length > 0) return value; + } + return undefined; +} + +function httpSchemeValue(value: string | undefined): string | undefined { + return value !== undefined && !SOCKS_SCHEMES.has(schemeOf(value) ?? '') ? value : undefined; +} + +function hasHttpProxy(env: Env): boolean { + return [ + firstNonBlank(env, ['http_proxy', 'HTTP_PROXY']), + firstNonBlank(env, ['https_proxy', 'HTTPS_PROXY']), + firstNonBlank(env, ['all_proxy', 'ALL_PROXY']), + ].some((value) => httpSchemeValue(value) !== undefined); +} + +function resolveHttpProxyUrls(env: Env): { httpProxy?: string; httpsProxy?: string } { + const allProxy = httpSchemeValue(firstNonBlank(env, ['all_proxy', 'ALL_PROXY'])); + return { + httpProxy: httpSchemeValue(firstNonBlank(env, ['http_proxy', 'HTTP_PROXY'])) ?? allProxy, + httpsProxy: httpSchemeValue(firstNonBlank(env, ['https_proxy', 'HTTPS_PROXY'])) ?? allProxy, + }; +} + +export function resolveSocksProxy(env: Env): SocksProxyConfig | undefined { + const candidates = [ + firstNonBlank(env, ['all_proxy', 'ALL_PROXY']), + firstNonBlank(env, ['https_proxy', 'HTTPS_PROXY']), + firstNonBlank(env, ['http_proxy', 'HTTP_PROXY']), + ]; + for (const value of candidates) { + if (value === undefined) continue; + const scheme = schemeOf(value); + if (scheme === undefined || !SOCKS_SCHEMES.has(scheme)) continue; + let url: URL; + try { + url = new URL(value); + } catch { + continue; + } + const config: SocksProxyConfig = { + type: scheme === 'socks4' || scheme === 'socks4a' ? 4 : 5, + host: url.hostname.replaceAll(/^\[|\]$/g, ''), + port: url.port ? Number(url.port) : 1080, + ...(url.username ? { userId: decodeURIComponent(url.username) } : {}), + ...(url.password ? { password: decodeURIComponent(url.password) } : {}), + }; + return config; + } + return undefined; +} + +export function isProxyConfigured(env: Env): boolean { + return hasHttpProxy(env) || resolveSocksProxy(env) !== undefined; +} + +export function resolveNoProxy(env: Env): string { + const raw = [env['no_proxy'], env['NO_PROXY']].find((value) => (value?.trim() ?? '').length > 0) ?? ''; + const hosts = raw + .split(',') + .map((host) => host.trim()) + .filter((host) => host.length > 0); + if (hosts.includes('*')) return '*'; + for (const loopback of LOOPBACK_NO_PROXY) { + if (!hosts.includes(loopback)) hosts.push(loopback); + } + return hosts.join(','); +} + +export function makeNoProxyMatcher(noProxy: string): (host: string, port?: number | string) => boolean { + const entries = noProxy + .split(',') + .map((entry) => entry.trim().toLowerCase()) + .filter((entry) => entry.length > 0); + if (entries.includes('*')) return () => true; + const parsed = entries.map(parseNoProxyEntry); + return (host: string, port?: number | string) => { + const target = host.toLowerCase().replaceAll(/^\[|\]$/g, ''); + const targetPort = port === undefined ? undefined : String(port); + return parsed.some( + ({ host: entry, port: entryPort }) => + (entryPort === undefined || entryPort === targetPort) && + (target === entry || target.endsWith(`.${entry}`)), + ); + }; +} + +function parseNoProxyEntry(entry: string): { host: string; port?: string } { + let host = entry; + let port: string | undefined; + if (entry.startsWith('[')) { + const close = entry.indexOf(']'); + host = entry.slice(1, close); + const rest = entry.slice(close + 1); + if (rest.startsWith(':')) port = rest.slice(1); + } else { + const colon = entry.indexOf(':'); + if (colon !== -1 && colon === entry.lastIndexOf(':') && /^\d+$/.test(entry.slice(colon + 1))) { + host = entry.slice(0, colon); + port = entry.slice(colon + 1); + } + } + if (host.startsWith('*.')) host = host.slice(2); + else if (host.startsWith('.')) host = host.slice(1); + return port === undefined ? { host } : { host, port }; +} + +export interface ProxyAgentFactories { + readonly makeHttpAgent: (options: { + httpProxy?: string; + httpsProxy?: string; + noProxy: string; + }) => Dispatcher; + readonly makeSocksAgent: (options: { proxy: SocksProxyConfig; noProxy: string }) => Dispatcher; +} + +const defaultMakeHttpAgent: ProxyAgentFactories['makeHttpAgent'] = ({ httpProxy, httpsProxy, noProxy }) => + new EnvHttpProxyAgent({ httpProxy, httpsProxy, noProxy }); + +const defaultMakeSocksAgent: ProxyAgentFactories['makeSocksAgent'] = ({ proxy, noProxy }) => { + const directConnect = buildConnector({}); + const bypass = makeNoProxyMatcher(noProxy); + const connect: typeof directConnect = (options, callback) => { + if (bypass(options.hostname, options.port)) { + directConnect(options, callback); + return; + } + void (async () => { + try { + const isTls = options.protocol === 'https:'; + const port = Number(options.port) || (isTls ? 443 : 80); + const { socket } = await SocksClient.createConnection({ + proxy: { host: proxy.host, port: proxy.port, type: proxy.type, userId: proxy.userId, password: proxy.password }, + command: 'connect', + destination: { host: options.hostname, port }, + }); + if (isTls) { + directConnect({ ...options, httpSocket: socket } as Parameters[0], callback); + } else { + socket.setNoDelay(true); + callback(null, socket); + } + } catch (error) { + callback(error instanceof Error ? error : new Error(String(error)), null); + } + })(); + }; + return new Agent({ connect }); +}; + +export function createProxyDispatcher( + env: Env, + factories: Partial = {}, +): Dispatcher | undefined { + const { makeHttpAgent = defaultMakeHttpAgent, makeSocksAgent = defaultMakeSocksAgent } = factories; + try { + if (hasHttpProxy(env)) { + const { httpProxy, httpsProxy } = resolveHttpProxyUrls(env); + return makeHttpAgent({ + httpProxy: httpProxy ?? '', + httpsProxy: httpsProxy ?? '', + noProxy: resolveNoProxy(env), + }); + } + const socks = resolveSocksProxy(env); + if (socks !== undefined) { + return makeSocksAgent({ proxy: socks, noProxy: resolveNoProxy(env) }); + } + return undefined; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + process.stderr.write(`kimi: ignoring invalid proxy configuration (${reason}); connecting directly\n`); + return undefined; + } +} + +export interface InstallProxyDeps { + readonly setGlobalDispatcher: (dispatcher: Dispatcher) => void; + readonly createProxyDispatcher: (env: Env) => Dispatcher | undefined; +} + +const defaultInstallProxyDeps: InstallProxyDeps = { + setGlobalDispatcher: undiciSetGlobalDispatcher, + createProxyDispatcher, +}; + +export function installGlobalProxyDispatcher( + env: Env, + deps: InstallProxyDeps = defaultInstallProxyDeps, +): boolean { + const dispatcher = deps.createProxyDispatcher(env); + if (dispatcher === undefined) return false; + deps.setGlobalDispatcher(dispatcher); + return true; +} + +export function proxyEnvForChild(env: Env): Record { + if (!hasHttpProxy(env)) return {}; + const noProxy = resolveNoProxy(env); + const result: Record = { + NODE_USE_ENV_PROXY: '1', + NO_PROXY: noProxy, + no_proxy: noProxy, + }; + const { httpProxy, httpsProxy } = resolveHttpProxyUrls(env); + if (httpProxy !== undefined) { + result['HTTP_PROXY'] = httpProxy; + result['http_proxy'] = httpProxy; + } + if (httpsProxy !== undefined) { + result['HTTPS_PROXY'] = httpsProxy; + result['https_proxy'] = httpsProxy; + } + return result; +} + +export function reconcileChildNoProxy( + childEnv: Record, + configEnv?: Record, +): void { + const override = [configEnv?.['no_proxy'], configEnv?.['NO_PROXY']].find( + (value) => (value?.trim() ?? '').length > 0, + ); + if (override === undefined) return; + const noProxy = resolveNoProxy({ no_proxy: override, NO_PROXY: override }); + childEnv['NO_PROXY'] = noProxy; + childEnv['no_proxy'] = noProxy; +} diff --git a/packages/agent-core-v2/src/_base/utils/render-prompt.ts b/packages/agent-core-v2/src/_base/utils/render-prompt.ts new file mode 100644 index 0000000000000000000000000000000000000000..2d41956f076e089fd5b803d6bc2fcaafb07cfbb1 --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/render-prompt.ts @@ -0,0 +1,10 @@ +const PROMPT_VARIABLE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g; + +export function renderPrompt(template: string, vars: Record): string { + return template.replace(PROMPT_VARIABLE, (match: string, name: string) => { + const value = vars[name]; + if (typeof value === 'string') return value; + if (typeof value === 'number') return String(value); + return match; + }); +} diff --git a/packages/agent-core-v2/src/_base/utils/retry.ts b/packages/agent-core-v2/src/_base/utils/retry.ts new file mode 100644 index 0000000000000000000000000000000000000000..120f0c1e1c2fbe737a2fd108e2d0028eed04142e --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/retry.ts @@ -0,0 +1,70 @@ +import { abortable } from '#/_base/utils/abort'; + +export const DEFAULT_MAX_RETRY_ATTEMPTS = 10; + +const BASE_DELAY_MS = 500; +const MAX_DELAY_MS = 32_000; +const RETRY_FACTOR = 2; +const JITTER_FACTOR = 0.25; + +export interface RetryErrorFields { + readonly errorName: string; + readonly errorMessage: string; + readonly statusCode?: number; +} + +export function retryBackoffDelay(attemptIndex: number): number { + const base = Math.min(BASE_DELAY_MS * Math.pow(RETRY_FACTOR, attemptIndex), MAX_DELAY_MS); + return base + Math.random() * JITTER_FACTOR * base; +} + +export function retryBackoffDelays(maxAttempts: number): number[] { + const count = Math.max(maxAttempts - 1, 0); + const delays: number[] = []; + for (let i = 0; i < count; i += 1) { + delays.push(retryBackoffDelay(i)); + } + return delays; +} + +export function readRetryAfterMs(error: unknown): number | null { + if (typeof error !== 'object' || error === null) return null; + const value = (error as { retryAfterMs?: unknown }).retryAfterMs; + return typeof value === 'number' && value > 0 ? value : null; +} + +export async function sleepForRetry(delayMs: number, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + const sleepPromise = sleep(delayMs); + if (signal === undefined) { + await sleepPromise; + return; + } + await abortable(sleepPromise, signal); +} + +export function retryErrorFields(error: unknown): RetryErrorFields { + return { + errorName: error instanceof Error ? error.name : typeof error, + errorMessage: error instanceof Error ? error.message : String(error), + statusCode: maybeStatusCode(error), + }; +} + +function sleep(delayMs: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, delayMs); + }); +} + +function maybeStatusCode(error: unknown): number | undefined { + if (typeof error !== 'object' || error === null) return undefined; + const statusCode = (error as { statusCode?: unknown }).statusCode; + if (typeof statusCode === 'number') return statusCode; + const details = (error as { details?: unknown }).details; + if (details !== null && typeof details === 'object') { + const detailsStatus = (details as { statusCode?: unknown }).statusCode; + if (typeof detailsStatus === 'number') return detailsStatus; + } + return undefined; +} diff --git a/packages/agent-core-v2/src/_base/utils/timer.ts b/packages/agent-core-v2/src/_base/utils/timer.ts new file mode 100644 index 0000000000000000000000000000000000000000..f6eee2496278bce2113fcae2ffd1d0466f39ce83 --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/timer.ts @@ -0,0 +1,76 @@ +import type { IDisposable } from '#/_base/di/lifecycle'; + +export const MAX_TIMER_DELAY_MS = 0x7fffffff; + +export function setClampedTimeout( + callback: () => void, + timeoutMs: number, +): ReturnType { + return setTimeout(callback, Math.min(timeoutMs, MAX_TIMER_DELAY_MS)); +} + +export interface IntervalTimerOptions { + readonly unref?: boolean; +} + +export class IntervalTimer implements IDisposable { + private handle: ReturnType | undefined; + + constructor(private readonly options: IntervalTimerOptions = {}) {} + + cancel(): void { + if (this.handle !== undefined) { + clearInterval(this.handle); + this.handle = undefined; + } + } + + cancelAndSet(runner: () => void, intervalMs: number): void { + this.cancel(); + const handle = setInterval(runner, intervalMs); + if ( + this.options.unref === true && + typeof handle === 'object' && + handle !== null && + 'unref' in handle + ) { + (handle as { unref: () => void }).unref(); + } + this.handle = handle; + } + + isSet(): boolean { + return this.handle !== undefined; + } + + dispose(): void { + this.cancel(); + } +} + +export class TimeoutTimer implements IDisposable { + private handle: ReturnType | undefined; + + cancel(): void { + if (this.handle !== undefined) { + clearTimeout(this.handle); + this.handle = undefined; + } + } + + cancelAndSet(runner: () => void, timeoutMs: number): void { + this.cancel(); + const handle = setTimeout(() => { + this.handle = undefined; + runner(); + }, timeoutMs); + if (typeof handle === 'object' && handle !== null && 'unref' in handle) { + (handle as { unref: () => void }).unref(); + } + this.handle = handle; + } + + dispose(): void { + this.cancel(); + } +} diff --git a/packages/agent-core-v2/src/_base/utils/typeEquality.ts b/packages/agent-core-v2/src/_base/utils/typeEquality.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c10c7dd8e2ae6565c577a47c3a5ec9cf56b7e0b --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/typeEquality.ts @@ -0,0 +1,8 @@ +type Flatten = { [K in keyof T]: T[K] } & {}; + +export type Equal = + (() => T extends Flatten ? 1 : 2) extends () => T extends Flatten ? 1 : 2 + ? true + : false; + +export type AssertExact = T; diff --git a/packages/agent-core-v2/src/_base/utils/types.ts b/packages/agent-core-v2/src/_base/utils/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..45a0d1c9dac42687ca35d69700c335744ac93aff --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/types.ts @@ -0,0 +1,13 @@ +export type Promisify = [T] extends [Promise] ? T : Promise; +export type PromisifyMethods = { + [K in keyof T]: T[K] extends (...args: infer Args) => infer Return + ? (...args: Args) => Promisify + : never; +}; + +export type Promisable = [T] extends [Promise] ? T | Awaited : T | Promise; +export type PromisableMethods = { + [K in keyof T]: T[K] extends (...args: infer Args) => infer Return + ? (...args: Args) => Promisable + : never; +}; diff --git a/packages/agent-core-v2/src/_base/utils/workdir-slug.ts b/packages/agent-core-v2/src/_base/utils/workdir-slug.ts new file mode 100644 index 0000000000000000000000000000000000000000..15ea2d5542d176b5dfd76253657de31c5dbad4bb --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/workdir-slug.ts @@ -0,0 +1,32 @@ +import { createHash } from 'node:crypto'; + +const MAX_WORKDIR_SLUG_LENGTH = 40; +const WORKDIR_KEY_PREFIX = 'wd_'; +const HASH_LENGTH = 12; + +export function slugifyWorkDirName(name: string): string { + const slug = name + .toLowerCase() + .replaceAll(/[^a-z0-9._-]+/g, '-') + .replaceAll(/^-+|-+$/g, '') + .slice(0, MAX_WORKDIR_SLUG_LENGTH) + .replaceAll(/^-+|-+$/g, ''); + return slug === '' || slug === '.' || slug === '..' ? 'workspace' : slug; +} + +export function encodeWorkDirKey(workDir: string): string { + const normalized = workDir.replace(/\\/g, '/').replace(/\/+$/, ''); + const base = normalized.split('/').pop() ?? normalized; + const slug = slugifyWorkDirName(base); + const hash = createHash('sha256').update(normalized).digest('hex').slice(0, HASH_LENGTH); + return `${WORKDIR_KEY_PREFIX}${slug}_${hash}`; +} + +const WIN_SHAPED = /^(?:[A-Za-z]:[\\/]|\\\\|\/\/)/; + +export function workspaceRootKey(root: string): string { + const slashed = root.replaceAll('\\', '/'); + const shaped = WIN_SHAPED.test(slashed); + const normalized = slashed.replace(/\/+$/, ''); + return shaped ? normalized.toLowerCase() : normalized; +} diff --git a/packages/agent-core-v2/src/_base/utils/xml-escape.ts b/packages/agent-core-v2/src/_base/utils/xml-escape.ts new file mode 100644 index 0000000000000000000000000000000000000000..6e5cb49ce58da576e21c7870e1ba00957b84a367 --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/xml-escape.ts @@ -0,0 +1,15 @@ +export function escapeXml(input: string): string { + return input + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +export function escapeXmlAttr(input: string): string { + return input.replaceAll('&', '&').replaceAll('"', '"'); +} + +export function escapeXmlTags(input: string): string { + return input.replaceAll('<', '<').replaceAll('>', '>'); +} diff --git a/packages/agent-core-v2/src/agent/actorService/agentActorService.ts b/packages/agent-core-v2/src/agent/actorService/agentActorService.ts new file mode 100644 index 0000000000000000000000000000000000000000..93fe6b01d2bf2e6a6d7427bb771f8f0e9aa2bc76 --- /dev/null +++ b/packages/agent-core-v2/src/agent/actorService/agentActorService.ts @@ -0,0 +1,154 @@ +import { createActor, type ActorLogic, type AnyActorRef, type Snapshot } from '#human/xstate2'; + +import { BugIndicatingError } from '#/_base/errors/errors'; +import { onUnexpectedError } from '#/_base/errors/unexpectedError'; +import { IInstantiationService, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; +import type { Event } from '#/_base/event'; +import type { AgentContext } from '#/agent/agentContext/agentContext'; +import type { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import type { Event2, Event2Class } from '#/app/event/event2'; +import type { IEventDispatcher } from '#/state/eventDispatcher'; +import type { DurableAgentRuntimeParticipant } from '#/state/eventDispatcher'; +import type { StateFold } from '#/state/state'; + +export interface AgentActorContext { + readonly agent: AgentContext; + get(id: ServiceIdentifier): T; + getState(): State; + getLogicState(): T; + dispatch(event: Event2): Promise; + send(event: unknown): void; + readonly onDidChange: Event; +} + +export interface AgentActorRestoreEvent { + readonly type: 'runtime.restore'; + waitUntil(work: Promise): void; +} + +export interface AgentActorDurable { + readonly events: readonly Event2Class[]; + readonly undoable: boolean; + readonly transition: StateFold; + read(snapshot: Snapshot): State; + commit(actor: AnyActorRef, state: State): void; +} + +export interface AgentActorOptions { + readonly id: string; + readonly input?: unknown; + readonly durable?: AgentActorDurable; +} + +export abstract class AgentActorService extends Disposable { + constructor( + private readonly dispatcher: IEventDispatcher, + private readonly scopeContext: IAgentScopeContext, + private readonly instantiation: IInstantiationService, + ) { + super(); + } + + protected attachActor( + logic: ActorLogic, + options: AgentActorOptions, + ): AgentActorContext { + const durable = options.durable; + const listeners = new Set<(state: State) => void>(); + let actor!: AnyActorRef; + const context: AgentActorContext = { + agent: this.scopeContext.agentContext, + get: (id) => this.instantiation.invokeFunction((accessor) => accessor.get(id)), + getState: () => { + if (durable === undefined) { + throw new BugIndicatingError(`Agent actor '${options.id}' has no durable state`); + } + return durable.read(actor.getSnapshot()); + }, + getLogicState: () => actor.getSnapshot().context as T, + dispatch: (event) => this.dispatcher.dispatch(event), + send: (event) => { actor.send(event); }, + onDidChange: (listener) => { + listeners.add(listener); + return toDisposable(() => { listeners.delete(listener); }); + }, + }; + actor = createActor(logic, { input: options.input ?? context }); + let previous: State | undefined; + const subscription = actor.subscribe({ + next: (snapshot) => { + if (durable === undefined) return; + const next = durable.read(snapshot); + if (Object.is(previous, next)) return; + if (previous !== undefined) { + for (const listener of listeners) listener(next); + } + previous = next; + }, + }); + actor.start(); + previous = durable?.read(actor.getSnapshot()); + let attachment: IDisposable | undefined; + const participant: DurableAgentRuntimeParticipant | undefined = + durable === undefined + ? undefined + : { + id: options.id, + events: durable.events, + undoable: durable.undoable, + transition: durable.transition, + getState: () => durable.read(actor.getSnapshot()), + commit: (state) => { durable.commit(actor, state); }, + }; + let disposed = false; + const sendRestore = async (): Promise => { + const readiness: Promise[] = []; + const event: AgentActorRestoreEvent = { + type: 'runtime.restore', + waitUntil: (work) => { readiness.push(work); }, + }; + actor.send(event); + await Promise.all(readiness); + }; + let restoreHook: IDisposable | undefined; + if (this.dispatcher.restorePhase === 'ready') { + queueMicrotask(() => { + void (async () => { + try { + if (participant !== undefined) { + const late = await this.dispatcher.attachLate(participant); + if (disposed) { + late.dispose(); + return; + } + attachment = late; + } + if (!disposed) await sendRestore(); + } catch (error) { + onUnexpectedError(error); + } + })(); + }); + } else { + if (participant !== undefined) { + attachment = this.dispatcher.attach(participant); + } + restoreHook = this.dispatcher.hooks.onDidRestore.register( + options.id, + async (_ctx, next) => { + await sendRestore(); + await next(); + }, + ); + } + this._register(toDisposable(() => { + disposed = true; + attachment?.dispose(); + restoreHook?.dispose(); + subscription.unsubscribe(); + actor.stop(); + })); + return context; + } +} diff --git a/packages/agent-core-v2/src/agent/agentContext/agentContext.ts b/packages/agent-core-v2/src/agent/agentContext/agentContext.ts new file mode 100644 index 0000000000000000000000000000000000000000..4c667450f8b4caf200189cc0651f428ec8b1e99e --- /dev/null +++ b/packages/agent-core-v2/src/agent/agentContext/agentContext.ts @@ -0,0 +1,7 @@ +import type { AgentSpace } from './agentSpace'; + +export interface AgentContext { + readonly agentId: string; + readonly generation: number; + readonly space: AgentSpace; +} diff --git a/packages/agent-core-v2/src/agent/agentContext/agentSpace.ts b/packages/agent-core-v2/src/agent/agentContext/agentSpace.ts new file mode 100644 index 0000000000000000000000000000000000000000..db963e274fc06514da95f500bda586e7c2b3d4c0 --- /dev/null +++ b/packages/agent-core-v2/src/agent/agentContext/agentSpace.ts @@ -0,0 +1,177 @@ +import { BugIndicatingError } from '#/_base/errors/errors'; +import { onUnexpectedError } from '#/_base/errors/unexpectedError'; +import type { StateKey } from '#/_base/state/stateRegistry'; +import type { Event2 } from '#/app/event/event2'; +import { + type AgentModel, + type AgentModelBridge, + type AgentModelDefinition, +} from '#/state/agentModel'; + +import type { AgentContext } from './agentContext'; + +export type AgentModelInstanceOf = D extends AgentModelDefinition ? M : never; + +export interface AgentSpace { + use, R>( + definition: D, + run: (model: AgentModelInstanceOf) => R, + ): R; +} + +export interface AgentSpaceHost { + isActiveModelDefinition(definition: AgentModelDefinition): boolean; + registerModel(definition: AgentModelDefinition, model: AgentModel): void; + dispatchModelEvent(event: Event2): Promise; + readLegacyState(key: StateKey): unknown; +} + +interface ModelEntry { + readonly definition: AgentModelDefinition; + readonly model: AgentModel; + leases: number; + retired: boolean; + disposed: boolean; +} + +export class AgentSpaceImpl implements AgentSpace { + private readonly instances = new Map, ModelEntry>(); + private host: AgentSpaceHost | undefined; + private context: AgentContext | undefined; + private dead = false; + + constructor(private readonly agentId: string) {} + + _bindContext(context: AgentContext): void { + this.context = context; + } + + _attachHost(host: AgentSpaceHost): void { + this.host = host; + } + + _detachHost(host: AgentSpaceHost): void { + if (this.host === host) this.host = undefined; + } + + use, R>( + definition: D, + run: (model: AgentModelInstanceOf) => R, + ): R { + const entry = this.ensureModel(definition); + entry.leases += 1; + let result: R; + try { + result = run(entry.model as AgentModelInstanceOf); + } catch (error) { + this.release(entry); + throw error; + } + if (result instanceof Promise) { + return result.finally(() => { + this.release(entry); + }) as R; + } + this.release(entry); + return result; + } + + ensureModel(definition: AgentModelDefinition): ModelEntry { + const existing = this.instances.get(definition); + if (existing !== undefined) return existing; + if (this.dead) { + throw new Error(`Agent ${this.agentId} space is disposed`); + } + const host = this.host; + if (host === undefined) { + throw new BugIndicatingError(`Agent ${this.agentId} space has no model host`); + } + if (!host.isActiveModelDefinition(definition)) { + throw new Error(`Model definition '${definition.id}' is unavailable`); + } + const context = this.context; + if (context === undefined) { + throw new BugIndicatingError(`Agent ${this.agentId} space is not bound to a context`); + } + const bridge: AgentModelBridge = { + dispatch: (event) => host.dispatchModelEvent(event), + readLegacy: (key) => host.readLegacyState(key), + initialState: () => Object.freeze(definition.state.initial()), + }; + const model = new definition.model({ agent: context, bridge }); + model._seal(); + validateApplierCoverage(definition, model); + const entry: ModelEntry = { definition, model, leases: 0, retired: false, disposed: false }; + this.instances.set(definition, entry); + host.registerModel(definition, model); + return entry; + } + + retireModel(definition: AgentModelDefinition): void { + const entry = this.instances.get(definition); + if (entry === undefined) return; + this.instances.delete(definition); + entry.retired = true; + if (entry.leases === 0) this.disposeEntry(entry); + } + + _kill(): void { + if (this.dead) return; + this.dead = true; + const entries = [...this.instances.values()]; + this.instances.clear(); + for (const entry of entries) { + entry.retired = true; + if (entry.leases === 0) this.disposeEntry(entry); + } + } + + private release(entry: ModelEntry): void { + entry.leases -= 1; + if (entry.leases === 0 && entry.retired) this.disposeEntry(entry); + } + + private disposeEntry(entry: ModelEntry): void { + if (entry.disposed) return; + entry.disposed = true; + try { + const result = entry.model.dispose(); + if (result instanceof Promise) { + result.catch((error: unknown) => onUnexpectedError(error)); + } + } catch (error) { + onUnexpectedError(error); + } + } +} + +function validateApplierCoverage( + definition: AgentModelDefinition, + model: AgentModel, +): void { + const registered = model._appliersTable(); + for (const cls of definition.events) { + if (!registered.has(cls)) { + throw new BugIndicatingError( + `Agent model '${definition.id}' does not apply declared event '${cls.type}'`, + ); + } + } + for (const cls of registered.keys()) { + if (!definition.events.includes(cls)) { + throw new BugIndicatingError( + `Agent model '${definition.id}' applies undeclared event '${cls.type}'`, + ); + } + } +} + +export function agentSpaceOf(agent: AgentContext): AgentSpace { + const space = (agent as { readonly space?: AgentSpace }).space; + if (space === undefined) { + throw new Error( + `Agent ${agent.agentId}:${String(agent.generation)} is not a lifecycle-issued context`, + ); + } + return space; +} diff --git a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminder.ts b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminder.ts new file mode 100644 index 0000000000000000000000000000000000000000..db0eba9e438039aa585c62ae5363d24437112395 --- /dev/null +++ b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminder.ts @@ -0,0 +1,10 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IAgentAgentsMdReminderService { + readonly _serviceBrand: undefined; + + seedInjected(paths: readonly string[], cwd: string): void; +} + +export const IAgentAgentsMdReminderService: ServiceIdentifier = + createDecorator('agentAgentsMdReminderService'); diff --git a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ab0d3f452bf28edf018733e27986f512404bd99 --- /dev/null +++ b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts @@ -0,0 +1,377 @@ +import { basename, dirname, isAbsolute, join, normalize } from 'pathe'; + +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { defineState } from '#/state/state'; +import { IBashParserService } from '#/app/bashParser/bashParser'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import type { AgentsMdReminderShownEvent } from '#/app/telemetry/events'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import type { WatchChange } from '#human/utils/watch'; +import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; +import { normalizeUserPath } from '#/tool/path-access'; +import { + AGENTS_MD_PLAIN_NAMES, + agentsMdCandidatePaths, + dirsRootToLeaf, + findAgentsMdInDir, + findProjectRoot, + extractAgentsMdPathsFromSystemPrompt, + loadAgentsMdDetailed, +} from '#/agent/profile/context'; +import { profileKey } from '#/agent/profile/profileOps'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { IAgentReminderService } from '#/features/reminder/reminderService'; +import type { + ContextInjectionContext, + ContextInjectionResult, +} from '#/features/reminder/types'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; +import type { ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; +import { IEventDispatcher } from '#/state/eventDispatcher'; + +import { IAgentAgentsMdReminderService } from './agentsMdReminder'; +import { extractBashTargetDirs } from './bashTargets'; + +const AGENTS_MD_BASENAMES: ReadonlySet = new Set(AGENTS_MD_PLAIN_NAMES); + +const BASH_PARSE_OPTIONS = { timeoutMs: 500, maxNodes: 10_000 } as const; + +const DISCOVERY_REMINDER_VARIANT = 'agents_md'; + +export const agentsMdReminderKnownKey = defineState>( + 'agentsMdReminder.known', + () => new Set(), +); +export const agentsMdReminderCwdKey = defineState( + 'agentsMdReminder.cwd', + () => undefined as string | undefined, +); +export const agentsMdReminderSeededKey = defineState( + 'agentsMdReminder.seeded', + () => false, +); + +export class AgentAgentsMdReminderService + extends Disposable + implements IAgentAgentsMdReminderService +{ + declare readonly _serviceBrand: undefined; + + private readonly remindQueue = new Set(); + private readonly readRecently = new Set(); + private readonly telemetryFired = new Set(); + + constructor( + @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, + @IAgentReminderService private readonly reminder: IAgentReminderService, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @IAgentStateService private readonly states: IAgentStateService, + @ISessionContext private readonly sessionContext: ISessionContext, + @IAgentRuntimeService private readonly runtime: IAgentRuntimeService, + @IBootstrapService private readonly bootstrap: IBootstrapService, + @IBashParserService private readonly bashParser: IBashParserService, + @ITelemetryService private readonly telemetry: ITelemetryService, + @IEventDispatcher private readonly dispatcher: IEventDispatcher, + @ISessionInstructionsProvider private readonly instructions: ISessionInstructionsProvider, + ) { + super(); + this.states.contributeState(agentsMdReminderKnownKey); + this.states.contributeState(agentsMdReminderCwdKey); + this.states.contributeState(agentsMdReminderSeededKey); + this._register( + this.reminder.register(DISCOVERY_REMINDER_VARIANT, (context) => + this.injectReminder(context), + ), + ); + this._register( + this.instructions.onDidChange((changes) => { + this.announceChanged(changes); + }), + ); + this._register( + this.dispatcher.hooks.onDidRestore.register('agentsMdReminder', async (_ctx, next) => { + const profile = this.states.get(profileKey); + const paths = + profile.agentsMdPaths ?? extractAgentsMdPathsFromSystemPrompt(profile.systemPrompt); + this.seedInjected(paths, this.sessionContext.cwd); + await next(); + }), + ); + const handler = async (ctx: ToolDidExecuteContext, next: () => Promise): Promise => { + await this.probeAndRemind(ctx); + await next(); + }; + this._register(toolExecutor.hooks.onDidExecuteTool.register('agentsMdReminder', handler)); + } + + seedInjected(paths: readonly string[], cwd: string): void { + const known = new Set(this.known); + for (const path of paths) known.add(normalize(path)); + this.states.set(agentsMdReminderKnownKey, known); + for (const path of paths) this.remindQueue.delete(normalize(path)); + this.states.set(agentsMdReminderCwdKey, cwd); + this.states.set(agentsMdReminderSeededKey, true); + } + + private announceChanged(changes: readonly WatchChange[]): void { + if (!this.states.get(agentsMdReminderSeededKey)) return; + const entries = new Map(); + for (const change of changes) { + const path = normalize(change.path); + entries.set(path, { ...change, path }); + } + if (entries.size === 0) return; + const list = [...entries.values()]; + this.reminder.notify(changeReminderText(list), { + variant: 'agents_md_change', + }); + this.markKnown( + list.filter((change) => change.action === 'modified').map((change) => change.path), + ); + this.markDeleted( + list.filter((change) => change.action === 'deleted').map((change) => change.path), + ); + } + + private get known(): Set { + return this.states.get(agentsMdReminderKnownKey); + } + + private get agentCwd(): string { + return this.states.get(agentsMdReminderCwdKey) ?? this.sessionContext.cwd; + } + + private injectReminder( + context: ContextInjectionContext, + ): ContextInjectionResult | undefined { + const readRecently = new Set(this.readRecently); + this.readRecently.clear(); + const known = this.known; + const queued = [...this.remindQueue].filter( + (path) => !known.has(path) && !readRecently.has(path), + ); + this.remindQueue.clear(); + if (queued.length === 0) return undefined; + const covered = context.lastDisclosure ?? []; + const fresh = queued.filter((path) => !covered.includes(path)); + if (fresh.length === 0) return undefined; + return { content: reminderText(fresh), disclosure: [...covered, ...fresh] }; + } + + private async ensureSeeded(): Promise { + if (this.states.get(agentsMdReminderSeededKey)) return; + const lease = this.runtime.acquire(['fs']); + try { + const { paths } = await loadAgentsMdDetailed( + { fs: lease.runtime.fs!, homeDir: lease.runtime.environment.homeDir }, + this.agentCwd, + this.bootstrap.homeDir, + ); + this.seedInjected(paths, this.agentCwd); + } finally { + lease.dispose(); + } + } + + private async probeAndRemind(ctx: ToolDidExecuteContext): Promise { + if (ctx.outcome !== 'executed') return; + try { + await this.ensureSeeded(); + const { dirs, selfKnown } = this.targetDirs(ctx); + const selfKnownSet = new Set(selfKnown); + const discovered: string[] = []; + for (const dir of dirs) { + for (const path of await this.probeDir(dir)) { + if (this.known.has(path) || this.remindQueue.has(path) || selfKnownSet.has(path)) { + continue; + } + discovered.push(path); + } + } + for (const path of selfKnown) { + this.remindQueue.delete(path); + this.readRecently.add(path); + } + if (discovered.length === 0) return; + const untracked = discovered.filter((path) => !this.telemetryFired.has(path)); + if (untracked.length > 0) { + const properties: AgentsMdReminderShownEvent = { + turn_id: ctx.turnId, + tool_name: ctx.toolCall.name, + reminded_count: untracked.length, + trace_id: ctx.trace?.traceId, + }; + this.telemetry.track2('agents_md_reminder_shown', properties); + for (const path of untracked) this.telemetryFired.add(path); + } + for (const path of discovered) this.remindQueue.add(path); + } catch {} + } + + private markKnown(paths: readonly string[]): void { + if (paths.length === 0) return; + const known = new Set(this.known); + for (const path of paths) known.add(path); + this.states.set(agentsMdReminderKnownKey, known); + } + + private markDeleted(paths: readonly string[]): void { + if (paths.length === 0) return; + const known = new Set(this.known); + for (const path of paths) { + known.delete(path); + this.remindQueue.delete(path); + this.telemetryFired.delete(path); + } + this.states.set(agentsMdReminderKnownKey, known); + } + + private targetDirs(ctx: ToolDidExecuteContext): { dirs: string[]; selfKnown: string[] } { + const selfKnown: string[] = []; + const lease = this.runtime.acquire(); + const env = lease.runtime.environment; + lease.dispose(); + switch (ctx.toolCall.name) { + case 'Read': + case 'Edit': + case 'Write': + case 'Glob': + case 'Grep': + return this.targetDirsFromAccesses(ctx); + case 'Bash': { + const args = ctx.args; + const command = stringArg(args, 'command'); + if (command === undefined) return { dirs: [], selfKnown }; + const cwdArg = stringArg(args, 'cwd'); + const base = hostPath(this.sessionContext.cwd, env.pathClass); + const normalizedCwdArg = + cwdArg === undefined ? undefined : normalizeUserPath(cwdArg, env.pathClass); + const effectiveCwd = + normalizedCwdArg === undefined + ? base + : normalize( + isAbsolute(normalizedCwdArg) + ? normalizedCwdArg + : join(base, normalizedCwdArg), + ); + const parsed = this.bashParser.parse(command, BASH_PARSE_OPTIONS); + if (!parsed.ok || parsed.hasError) { + return normalizedCwdArg === undefined + ? { dirs: [], selfKnown } + : { dirs: [effectiveCwd], selfKnown }; + } + const targets = extractBashTargetDirs( + parsed.root, + effectiveCwd, + env.homeDir, + ).map((target) => hostPath(target, env.pathClass)); + if (normalizedCwdArg !== undefined && !targets.includes(effectiveCwd)) { + targets.unshift(effectiveCwd); + } + return { dirs: targets, selfKnown }; + } + default: + return { dirs: [], selfKnown }; + } + } + + private targetDirsFromAccesses(ctx: ToolDidExecuteContext): { + dirs: string[]; + selfKnown: string[]; + } { + const dirs: string[] = []; + const selfKnown: string[] = []; + const targetsFiles = + ctx.toolCall.name === 'Read' || + ctx.toolCall.name === 'Edit' || + ctx.toolCall.name === 'Write'; + for (const access of ctx.accesses ?? []) { + if (access.kind !== 'file') continue; + if ( + targetsFiles && + ctx.result.isError !== true && + AGENTS_MD_BASENAMES.has(basename(access.path)) + ) { + selfKnown.push(access.path); + } + dirs.push(targetsFiles ? dirname(access.path) : access.path); + } + return { dirs: [...new Set(dirs)], selfKnown: [...new Set(selfKnown)] }; + } + + private async probeDir(dir: string): Promise { + const lease = this.runtime.acquire(['fs']); + try { + const fs = lease.runtime.fs!; + const anchor = await this.nearestExistingDir(fs, dir); + if (anchor === undefined) return []; + const deps = { fs }; + const projectRoot = await findProjectRoot(deps, anchor); + const chain = dirsRootToLeaf(anchor, projectRoot); + const found: string[] = []; + for (const chainDir of chain) { + const candidates = agentsMdCandidatePaths(chainDir); + if (candidates.every((candidate) => this.known.has(normalize(candidate)))) continue; + for (const path of await findAgentsMdInDir(deps, chainDir)) { + found.push(normalize(path)); + } + } + return found; + } finally { + lease.dispose(); + } + } + + private async nearestExistingDir(fs: IHostFileSystem, path: string): Promise { + let current = path; + for (;;) { + const stat = await fs.stat(current).catch(() => undefined); + if (stat?.isDirectory === true) return current; + const parent = dirname(current); + if (parent === current) return undefined; + current = parent; + } + } +} + +function hostPath(path: string, pathClass: 'posix' | 'win32'): string { + return normalize(normalizeUserPath(path, pathClass)); +} + +function stringArg(args: unknown, key: string): string | undefined { + if (typeof args !== 'object' || args === null) return undefined; + const value = (args as Record)[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function reminderText(paths: readonly string[]): string { + return ( + 'The following AGENTS.md file(s) apply to paths accessed by your recent tool call, but were not included in your system prompt:\n' + + paths.map((path) => `- ${path}`).join('\n') + + '\nRead them before making changes in those directories.' + ); +} + +function changeReminderText(changes: readonly WatchChange[]): string { + return ( + 'The AGENTS.md instruction file(s) below changed on disk after they were injected into the system prompt:\n' + + changes + .map((change) => `- ${change.path}${change.action === 'deleted' ? ' (deleted)' : ''}`) + .join('\n') + + '\nRead the current file(s) and follow the latest contents; the copies injected in the system prompt are stale.' + ); +} + +registerScopedService( + LifecycleScope.Agent, + IAgentAgentsMdReminderService, + AgentAgentsMdReminderService, + ScopeActivation.OnScopeCreated, + 'agentsMdReminder', +); diff --git a/packages/agent-core-v2/src/agent/agentsMdReminder/bashTargets.ts b/packages/agent-core-v2/src/agent/agentsMdReminder/bashTargets.ts new file mode 100644 index 0000000000000000000000000000000000000000..418057bbef546df554ee4aa5dd8bcdf5bec0fb68 --- /dev/null +++ b/packages/agent-core-v2/src/agent/agentsMdReminder/bashTargets.ts @@ -0,0 +1,226 @@ +import { isAbsolute, join, normalize } from 'pathe'; + +import type { BashSyntaxNode } from '#/app/bashParser/bashParser'; + +const LISTING_COMMANDS: ReadonlySet = new Set([ + 'ls', + 'tree', + 'find', + 'dir', + 'exa', + 'eza', + 'lsd', +]); + +const TRANSPARENT_WRAPPERS: ReadonlySet = new Set([ + 'program', + 'list', + 'pipeline', + 'redirected_statement', +]); + +const LS_ARG_TAKING_OPTIONS: ReadonlySet = new Set([ + '-w', + '--width', + '--sort', + '--block-size', + '-I', + '--ignore', + '--hide', + '--format', + '--time-style', + '--indicator-style', + '--quoting-style', + '-T', + '--tabsize', +]); + +const TREE_LIKE_ARG_TAKING_OPTIONS: ReadonlySet = new Set([ + '-w', + '--width', + '--sort', + '--block-size', + '-I', + '--ignore', + '--ignore-glob', + '--hide', + '--format', + '--time-style', + '--indicator-style', + '--hyperlink', + '--quoting-style', + '-T', + '--tabsize', + '-L', + '--level', + '--depth', + '-P', + '-o', + '--filelimit', + '--charset', + '--timefmt', + '-s', +]); + +const FIND_GLOBAL_OPTIONS: ReadonlySet = new Set(['-H', '-L', '-P']); + +const UNSAFE_OPERAND = /[$`*?[\]~]/; + +export function extractBashTargetDirs( + root: BashSyntaxNode, + cwd: string, + homeDir: string, +): string[] { + const commands: CollectedCommand[] = []; + collectCommands(root, commands); + + const targets: string[] = []; + const seen = new Set(); + let base: string | undefined = cwd; + const push = (operand: string): void => { + let dir: string; + if (isAbsolute(operand)) { + dir = normalize(operand); + } else { + const currentBase = base; + if (currentBase === undefined) return; + dir = normalize(join(currentBase, operand)); + } + if (seen.has(dir)) return; + seen.add(dir); + targets.push(dir); + }; + + for (const { node, inPipeline } of commands) { + const { name, args, dropped } = commandNameAndArgs(node); + if (name === undefined) continue; + if (name === 'cd') { + if (inPipeline) continue; + if (dropped) { + base = undefined; + continue; + } + const target = args[0]; + if (args.length === 1 && target !== undefined && !target.startsWith('-')) { + if (isAbsolute(target)) { + base = normalize(target); + } else if (base !== undefined) { + base = normalize(join(base, target)); + } + } else if (args.length === 0) { + base = homeDir; + } else { + base = undefined; + } + continue; + } + if (!LISTING_COMMANDS.has(name)) continue; + const operands = name === 'find' ? takeLeadingPaths(args) : dropFlags(args, name); + if (operands.length === 0) { + if (!dropped) push('.'); + continue; + } + for (const operand of operands) push(operand); + } + return targets; +} + +interface CollectedCommand { + readonly node: BashSyntaxNode; + readonly inPipeline: boolean; +} + +function collectCommands(node: BashSyntaxNode, out: CollectedCommand[], inPipeline = false): void { + if (node.type === 'command') { + out.push({ node, inPipeline }); + return; + } + if (!TRANSPARENT_WRAPPERS.has(node.type)) return; + const nested = inPipeline || node.type === 'pipeline'; + for (const child of node.children) collectCommands(child, out, nested); +} + +function commandNameAndArgs(command: BashSyntaxNode): { + name: string | undefined; + args: string[]; + dropped: boolean; +} { + const nameIndex = command.children.findIndex((child) => child.type === 'command_name'); + const nameNode = nameIndex >= 0 ? command.children[nameIndex] : undefined; + const nameWord = nameNode?.children.find((child) => child.isNamed); + const rawName = nameWord === undefined ? undefined : literalText(nameWord); + if (rawName === undefined || rawName.length === 0 || rawName.includes('/')) { + return { name: undefined, args: [], dropped: false }; + } + const args: string[] = []; + let dropped = false; + for (const child of command.children.slice(nameIndex + 1)) { + const value = literalText(child); + if (value === undefined) { + dropped = true; + } else if (value.length > 0) { + args.push(value); + } + } + return { name: rawName, args, dropped }; +} + +function literalText(node: BashSyntaxNode): string | undefined { + switch (node.type) { + case 'word': { + const raw = node.text; + if (UNSAFE_OPERAND.test(raw)) return undefined; + const unescaped = raw.replaceAll(/\\(.)/gs, '$1'); + return UNSAFE_OPERAND.test(unescaped) ? undefined : unescaped; + } + case 'number': + return node.text; + case 'raw_string': { + if (node.text.length < 2) return undefined; + const value = node.text.slice(1, -1); + return UNSAFE_OPERAND.test(value) ? undefined : value; + } + case 'string': { + let value = ''; + for (const child of node.children) { + if (child.type === 'string_content') { + value += child.text; + } else if (child.isNamed) { + return undefined; + } + } + return UNSAFE_OPERAND.test(value) ? undefined : value; + } + default: + return undefined; + } +} + +function dropFlags(args: readonly string[], command: string): string[] { + const argumentTakingOptions = + command === 'ls' || command === 'dir' + ? LS_ARG_TAKING_OPTIONS + : TREE_LIKE_ARG_TAKING_OPTIONS; + const out: string[] = []; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]!; + if (!arg.startsWith('-')) { + out.push(arg); + continue; + } + if (!arg.includes('=') && argumentTakingOptions.has(arg)) { + i += 1; + } + } + return out; +} + +function takeLeadingPaths(args: readonly string[]): string[] { + const out: string[] = []; + for (const arg of args) { + if (FIND_GLOBAL_OPTIONS.has(arg) || arg === '--') continue; + if (arg.startsWith('-') || arg === '(' || arg === ')' || arg === '!') break; + out.push(arg); + } + return out; +} diff --git a/packages/agent-core-v2/src/agent/blob/agentBlobService.ts b/packages/agent-core-v2/src/agent/blob/agentBlobService.ts new file mode 100644 index 0000000000000000000000000000000000000000..4d1da5a13e80562f8728ecb27731c3ac8af41107 --- /dev/null +++ b/packages/agent-core-v2/src/agent/blob/agentBlobService.ts @@ -0,0 +1,18 @@ +import type { ContentPart } from '#human/llm/message'; + +import { createDecorator } from "#/_base/di/instantiation"; + +export const BLOBREF_PROTOCOL = 'blobref:'; +export const MISSING_MEDIA_PLACEHOLDER = '[media missing]'; + +export interface IAgentBlobService { + readonly _serviceBrand: undefined; + + offloadParts(parts: readonly ContentPart[]): Promise; + loadParts(parts: readonly ContentPart[]): Promise; + isBlobRef(url: string): boolean; +} + +export const IAgentBlobService = createDecorator( + 'agentBlobService', +); diff --git a/packages/agent-core-v2/src/agent/blob/agentBlobServiceImpl.ts b/packages/agent-core-v2/src/agent/blob/agentBlobServiceImpl.ts new file mode 100644 index 0000000000000000000000000000000000000000..8123bb1514ba3d13b3e1d712e33740498d8cfdcb --- /dev/null +++ b/packages/agent-core-v2/src/agent/blob/agentBlobServiceImpl.ts @@ -0,0 +1,159 @@ +import { createHash } from 'node:crypto'; +import type { ContentPart } from '#human/llm/message'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IBlobStore } from '#/persistence/interface/blobStore'; +import { + BLOBREF_PROTOCOL, + IAgentBlobService, + MISSING_MEDIA_PLACEHOLDER, +} from './agentBlobService'; +import { ByteLruCache } from './byteLruCache'; + +const DEFAULT_THRESHOLD = 4096; +const DEFAULT_MAX_CACHE_SIZE = 50 * 1024 * 1024; +const DATA_URI_HEADER_RE = /^data:([^;]+);base64,/; + +export class AgentBlobServiceImpl implements IAgentBlobService { + declare readonly _serviceBrand: undefined; + + private readonly storageScope: string; + private readonly cache = new ByteLruCache(DEFAULT_MAX_CACHE_SIZE); + + constructor( + @IBlobStore private readonly blobs: IBlobStore, + @IAgentScopeContext agentCtx: IAgentScopeContext, + ) { + this.storageScope = agentCtx.scope('blobs'); + } + + protected get threshold(): number { + return DEFAULT_THRESHOLD; + } + + isBlobRef(url: string): boolean { + return url.startsWith(BLOBREF_PROTOCOL); + } + + async offloadParts(parts: readonly ContentPart[]): Promise { + let changed = false; + const out: ContentPart[] = []; + for (const part of parts) { + const next = await this.offloadContentPart(part); + if (next !== part) changed = true; + out.push(next); + } + return changed ? out : parts; + } + + async loadParts(parts: readonly ContentPart[]): Promise { + let changed = false; + const out: ContentPart[] = []; + for (const part of parts) { + const next = await this.loadContentPart(part); + if (next !== part) changed = true; + out.push(next); + } + return changed ? out : parts; + } + + private offloadContentPart(part: ContentPart): Promise { + return this.rewriteMediaUrls(part, (url) => this.maybeOffloadString(url)); + } + + private loadContentPart(part: ContentPart): Promise { + return this.rewriteMediaUrls(part, async (url) => { + if (!this.isBlobRef(url)) return url; + return (await this.loadBlobRefUrl(url)) ?? MISSING_MEDIA_PLACEHOLDER; + }); + } + + private async rewriteMediaUrls( + part: ContentPart, + transformUrl: (url: string) => Promise, + ): Promise { + let updated: Record | undefined; + for (const [key, value] of Object.entries(part)) { + const mediaObj = asMediaContainer(value); + if (mediaObj === undefined) continue; + + const url = mediaObj.url; + if (typeof url !== 'string') continue; + + const newUrl = await transformUrl(url); + if (newUrl === url) continue; + + if (updated === undefined) updated = { ...part }; + updated[key] = { ...(value as object), url: newUrl }; + } + return updated === undefined ? part : (updated as unknown as ContentPart); + } + + private async loadBlobRefUrl(url: string): Promise { + const ref = parseBlobRef(url); + if (ref === undefined) return undefined; + + const payload = await this.readBlob(ref.hash); + if (payload === undefined) return undefined; + + return formatDataUri(ref.mimeType, payload); + } + + private async readBlob(hash: string): Promise { + const cached = this.cache.get(hash); + if (cached !== undefined) return cached; + + const payload = await this.blobs.get(this.storageScope, hash).catch(() => undefined); + if (payload === undefined) return undefined; + + const buffer = Buffer.from(payload); + this.cache.set(hash, buffer); + return buffer; + } + + private async maybeOffloadString(value: string): Promise { + if (this.isBlobRef(value)) return value; + + const match = DATA_URI_HEADER_RE.exec(value); + if (match === null) return value; + + const mimeType = match[1]!; + const payload = value.slice(match[0].length); + if (payload.length < this.threshold) return value; + + return this.writeBlob(mimeType, payload); + } + + private async writeBlob(mimeType: string, base64Payload: string): Promise { + const hash = createHash('sha256').update(base64Payload, 'utf8').digest('hex'); + const binary = Buffer.from(base64Payload, 'base64'); + await this.blobs.put(this.storageScope, hash, binary); + this.cache.set(hash, binary); + return formatBlobRef(mimeType, hash); + } +} + +function formatBlobRef(mimeType: string, hash: string): string { + return `${BLOBREF_PROTOCOL}${mimeType};${hash}`; +} + +function parseBlobRef(url: string): { mimeType: string; hash: string } | undefined { + if (!url.startsWith(BLOBREF_PROTOCOL)) return undefined; + const rest = url.slice(BLOBREF_PROTOCOL.length); + const semiIdx = rest.indexOf(';'); + if (semiIdx === -1) return undefined; + const hash = rest.slice(semiIdx + 1); + if (hash.length === 0) return undefined; + return { mimeType: rest.slice(0, semiIdx), hash }; +} + +function formatDataUri(mimeType: string, payload: Buffer): string { + return `data:${mimeType};base64,${payload.toString('base64')}`; +} + +function asMediaContainer(value: unknown): { url: unknown } | undefined { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + const obj = value as Record; + return 'url' in obj ? (obj as { url: unknown }) : undefined; +} diff --git a/packages/agent-core-v2/src/agent/blob/byteLruCache.ts b/packages/agent-core-v2/src/agent/blob/byteLruCache.ts new file mode 100644 index 0000000000000000000000000000000000000000..0a182d9593bc84af9882d5aee19aa732d8cbcd8b --- /dev/null +++ b/packages/agent-core-v2/src/agent/blob/byteLruCache.ts @@ -0,0 +1,47 @@ +export class ByteLruCache { + private readonly map = new Map(); + private currentBytes = 0; + + constructor(private readonly maxBytes: number) {} + + get(key: string): Buffer | undefined { + const value = this.map.get(key); + if (value === undefined) return undefined; + this.map.delete(key); + this.map.set(key, value); + return value; + } + + set(key: string, value: Buffer): void { + const size = value.byteLength; + const existing = this.map.get(key); + + if (size > this.maxBytes) { + if (existing !== undefined) { + this.currentBytes -= existing.byteLength; + this.map.delete(key); + } + return; + } + + if (existing !== undefined) { + this.currentBytes -= existing.byteLength; + this.map.delete(key); + } else { + while (this.map.size > 0 && this.currentBytes + size > this.maxBytes) { + this.evictOldest(); + } + } + + this.currentBytes += size; + this.map.set(key, value); + } + + private evictOldest(): void { + const oldest = this.map.keys().next().value; + if (oldest === undefined) return; + const value = this.map.get(oldest)!; + this.currentBytes -= value.byteLength; + this.map.delete(oldest); + } +} diff --git a/packages/agent-core-v2/src/agent/command/agentCommand.ts b/packages/agent-core-v2/src/agent/command/agentCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..16ece59242157f79a688f0fa6d7c80b255c49bff --- /dev/null +++ b/packages/agent-core-v2/src/agent/command/agentCommand.ts @@ -0,0 +1,18 @@ +import type { Event } from '#/_base/event'; +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface AgentCommandInfo { + readonly name: string; + readonly description?: string; + readonly source: string; +} + +export interface IAgentCommandService { + readonly _serviceBrand: undefined; + readonly onDidChange: Event; + list(): readonly AgentCommandInfo[]; + run(name: string, args?: string): Promise; +} + +export const IAgentCommandService: ServiceIdentifier = + createDecorator('agentCommandService'); diff --git a/packages/agent-core-v2/src/agent/command/agentCommandService.ts b/packages/agent-core-v2/src/agent/command/agentCommandService.ts new file mode 100644 index 0000000000000000000000000000000000000000..33fde5a27658be2b44a0d5008063e05ce1879e49 --- /dev/null +++ b/packages/agent-core-v2/src/agent/command/agentCommandService.ts @@ -0,0 +1,66 @@ +import { Emitter, type Event } from '#/_base/event'; +import { type CollectionRecord, type CollectionView } from '#/_base/di/collection'; +import { + IInstantiationService, + type ServiceIdentifier, +} from '#/_base/di/instantiation'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Error2, ErrorCodes } from '#/errors'; + +import { IAgentCommandService, type AgentCommandInfo } from './agentCommand'; +import { CommandContribution } from './commandContribution'; + +export class AgentCommandService extends Service implements IAgentCommandService { + declare readonly _serviceBrand: undefined; + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + constructor( + @IInstantiationService private readonly instantiationService: IInstantiationService, + @CommandContribution private readonly contributions: CollectionView, + ) { + super(); + this._register(this.contributions.onDidChange(() => this._onDidChange.fire())); + } + + list(): readonly AgentCommandInfo[] { + const byName = new Map(); + for (const record of this.contributions.records) { + byName.set(record.value.name, { + name: record.value.name, + description: record.value.description, + source: record.providerName, + }); + } + return [...byName.values()]; + } + + async run(name: string, args = ''): Promise { + const record = this.find(name); + if (record === undefined) { + throw new Error2(ErrorCodes.REQUEST_INVALID, `Unknown command "${name}"`); + } + await this.instantiationService.invokeFunction((accessor) => + record.value.run({ args, get: (id: ServiceIdentifier): T => accessor.get(id) }), + ); + } + + private find(name: string): CollectionRecord | undefined { + let found: CollectionRecord | undefined; + for (const record of this.contributions.records) { + if (record.value.name === name) found = record; + } + return found; + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentCommandService, + AgentCommandService, + ScopeActivation.OnDemand, + 'command', +); diff --git a/packages/agent-core-v2/src/agent/command/commandContribution.ts b/packages/agent-core-v2/src/agent/command/commandContribution.ts new file mode 100644 index 0000000000000000000000000000000000000000..f902c3a6f0d4d736f68ee8cdbec95dfed025c9f0 --- /dev/null +++ b/packages/agent-core-v2/src/agent/command/commandContribution.ts @@ -0,0 +1,15 @@ +import { collection } from '#/_base/di/collection'; +import type { ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface CommandRunContext { + readonly args: string; + get(id: ServiceIdentifier): T; +} + +export interface CommandContribution { + readonly name: string; + readonly description?: string; + readonly run: (ctx: CommandRunContext) => void | Promise; +} + +export const CommandContribution = collection('command'); diff --git a/packages/agent-core-v2/src/agent/contextMemory/compaction-summary-prefix.md b/packages/agent-core-v2/src/agent/contextMemory/compaction-summary-prefix.md new file mode 100644 index 0000000000000000000000000000000000000000..3b8345bf3456eae5119c0147469a44974d68557f --- /dev/null +++ b/packages/agent-core-v2/src/agent/contextMemory/compaction-summary-prefix.md @@ -0,0 +1 @@ +The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over. Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it. Any user messages earlier in this context are preserved verbatim from the compacted conversation; where a system-reminder note among them marks an omitted middle section, the user messages it replaced are covered by this summary. The summary records which earlier requests were already addressed. diff --git a/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts new file mode 100644 index 0000000000000000000000000000000000000000..60f43a0155f2ea965bc69768eaf78720c3970bd2 --- /dev/null +++ b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts @@ -0,0 +1,367 @@ +import { estimateTokens, estimateTokensForMessage, estimateTokensForMessages } from '#/llm-adapter/contract/tokens'; +import type { ContentPart } from '#human/llm/message'; +import { wrapSystemReminder } from '#/features/reminder/systemReminder'; +import summaryPrefixTemplate from './compaction-summary-prefix.md?raw'; +import type { ContextMessage, PromptOrigin } from './types'; + +export const COMPACTION_SUMMARY_PREFIX = summaryPrefixTemplate.trimEnd(); +export const COMPACT_USER_MESSAGE_MAX_TOKENS = 20_000; +export const COMPACT_USER_MESSAGE_HEAD_TOKENS = 2_000; +export const COMPACTION_ELISION_VARIANT = 'compaction_elision'; +export const COMPACTION_CONTINUATION_VARIANT = 'compaction_continuation'; + +type MessageLike = ContextMessage; + +export interface TokenEstimate { + readonly text: (text: string) => number; + readonly message: (message: MessageLike) => number; + readonly messages: (messages: readonly MessageLike[]) => number; +} + +export const defaultTokenEstimate: TokenEstimate = { + text: estimateTokens, + message: estimateTokensForMessage, + messages: estimateTokensForMessages, +}; + +export interface CompactionUserSelection { + readonly head: T[]; + readonly tail: T[]; + readonly elided: boolean; + readonly omittedTokens: number; +} + +export interface ContextCompactionShapeInput { + readonly summary: string; + readonly legacySummaryMessage?: ContextMessage; + readonly contextSummary?: string; + readonly compactedCount: number; + readonly tokensBefore: number; + readonly tokensAfter?: number; + readonly summaryOutputTokens?: number; + readonly requestOverheadTokens?: number; + readonly keptUserMessageCount?: number; + readonly keptHeadUserMessageCount?: number; + readonly droppedCount?: number; + readonly legacyTail?: boolean; +} + +export interface ContextCompactionShape { + readonly summary: string; + readonly contextSummary: string; + readonly compactedCount: number; + readonly tokensBefore: number; + readonly tokensAfter: number; + readonly keptUserMessageCount: number; + readonly keptHeadUserMessageCount?: number; + readonly droppedCount?: number; + readonly messages: readonly ContextMessage[]; +} + +export function buildContextCompactionShape( + history: readonly ContextMessage[], + input: ContextCompactionShapeInput, + estimate: TokenEstimate = defaultTokenEstimate, +): ContextCompactionShape { + if (usesLegacyTailShape(input)) { + const contextSummary = input.contextSummary ?? input.summary; + const messages = [ + input.legacySummaryMessage ?? createCompactionSummaryMessage(contextSummary), + ...history.slice(input.compactedCount), + ]; + return { + summary: input.summary, + contextSummary, + compactedCount: input.compactedCount, + tokensBefore: input.tokensBefore, + tokensAfter: input.tokensAfter ?? estimate.messages(messages), + keptUserMessageCount: 0, + droppedCount: input.droppedCount, + messages, + }; + } + + const compactableUserMessages = collectCompactableUserMessages(history); + const selection = selectCompactionUserMessages( + compactableUserMessages, + COMPACT_USER_MESSAGE_MAX_TOKENS, + COMPACT_USER_MESSAGE_HEAD_TOKENS, + estimate.message, + ); + const elisionMessage = selection.elided + ? createCompactionElisionMessage(selection.omittedTokens) + : undefined; + const keptMessages = elisionMessage === undefined + ? [...selection.head, ...selection.tail] + : [...selection.head, elisionMessage, ...selection.tail]; + const contextSummary = input.contextSummary ?? input.summary; + const continuationMessage = createCompactionContinuationMessage(); + const tokensAfter = + input.tokensAfter ?? + (input.requestOverheadTokens ?? 0) + + (input.summaryOutputTokens ?? estimate.text(contextSummary)) + + estimate.messages([...keptMessages, continuationMessage]); + const keptUserMessageCount = + input.keptUserMessageCount ?? selection.head.length + selection.tail.length; + const keptHeadUserMessageCount = + input.keptHeadUserMessageCount ?? (selection.elided ? selection.head.length : undefined); + + return { + summary: input.summary, + contextSummary, + compactedCount: input.compactedCount, + tokensBefore: input.tokensBefore, + tokensAfter, + keptUserMessageCount, + keptHeadUserMessageCount, + droppedCount: input.droppedCount, + messages: [ + ...keptMessages, + createCompactionSummaryMessage(contextSummary), + continuationMessage, + ], + }; +} + +export function buildCompactionSummaryText(summary: string): string { + const suffix = summary.trim(); + return `${COMPACTION_SUMMARY_PREFIX}\n${suffix.length > 0 ? suffix : '(no summary available)'}`; +} + +export function createCompactionSummaryMessage(text: string): ContextMessage { + return { + role: 'user', + content: [{ type: 'text', text }], + toolCalls: [], + origin: { kind: 'compaction_summary' }, + }; +} + +export function createCompactionElisionMessage(omittedTokens: number): ContextMessage { + return { + role: 'user', + content: [{ type: 'text', text: buildCompactionElisionText(omittedTokens) }], + toolCalls: [], + origin: { kind: 'injection', variant: COMPACTION_ELISION_VARIANT }, + }; +} + +export function buildCompactionElisionText(omittedTokens: number): string { + return wrapSystemReminder( + `Some of this conversation's user messages were omitted here during compaction: the messages above this note are the oldest user input, the messages below are the most recent, and roughly ${String(omittedTokens)} tokens in between were dropped. The omitted content is covered by the compaction summary at the end of the conversation.`, + ); +} + +export function createCompactionContinuationMessage(): ContextMessage { + return { + role: 'user', + content: [{ type: 'text', text: buildCompactionContinuationText() }], + toolCalls: [], + origin: { kind: 'injection', variant: COMPACTION_CONTINUATION_VARIANT }, + }; +} + +export function buildCompactionContinuationText(): string { + return wrapSystemReminder( + 'Context compaction is complete — continue the work that was in progress when it began.', + ); +} + +export function collectCompactableUserMessages(messages: readonly T[]): T[] { + return messages.filter( + (message) => isRealUserInput(message) && !isCompactionSummaryMessage(message), + ); +} + +export function isCompactionSummaryMessage(message: MessageLike): boolean { + return message.origin?.kind === 'compaction_summary'; +} + +export function isRealUserInput(message: MessageLike): boolean { + return message.role === 'user' && compactionUserMessageDisposition(message.origin) === 'keep'; +} + +export function compactionUserMessageDisposition( + origin: PromptOrigin | undefined, +): 'keep' | 'drop' { + if (origin === undefined) return 'keep'; + switch (origin.kind) { + case 'user': + return 'keep'; + case 'skill_activation': + case 'plugin_command': + return origin.trigger === 'user-slash' ? 'keep' : 'drop'; + case 'injection': + case 'shell_command': + case 'compaction_summary': + case 'system_trigger': + case 'task': + case 'cron_job': + case 'cron_missed': + case 'hook_result': + case 'retry': + return 'drop'; + default: { + const exhaustive: never = origin; + void exhaustive; + return 'drop'; + } + } +} + +export function selectRecentUserMessages( + messages: readonly T[], + maxTokens: number = COMPACT_USER_MESSAGE_MAX_TOKENS, + estimateMessage: (message: T) => number = estimateTokensForMessage, +): T[] { + const selected: T[] = []; + let remaining = maxTokens; + for (let i = messages.length - 1; i >= 0 && remaining > 0; i--) { + const message = messages[i]!; + const tokens = estimateMessage(message); + if (tokens <= remaining) { + selected.push(message); + remaining -= tokens; + } else { + selected.push(truncateUserMessage(message, remaining)); + break; + } + } + selected.reverse(); + return selected; +} + +export function selectCompactionUserMessages( + messages: readonly T[], + maxTokens: number = COMPACT_USER_MESSAGE_MAX_TOKENS, + headTokens: number = COMPACT_USER_MESSAGE_HEAD_TOKENS, + estimateMessage: (message: T) => number = estimateTokensForMessage, +): CompactionUserSelection { + let totalTokens = 0; + for (const message of messages) { + totalTokens += estimateMessage(message); + } + if (totalTokens <= maxTokens) { + return { head: [], tail: [...messages], elided: false, omittedTokens: 0 }; + } + + const headBudget = Math.min(Math.max(headTokens, 0), maxTokens); + const tailBudget = maxTokens - headBudget; + const tail: T[] = []; + let tailRemaining = tailBudget; + let headEndExclusive = messages.length; + let tailBoundaryDroppedPrefix: T | null = null; + for (let i = messages.length - 1; i >= 0 && tailRemaining > 0; i--) { + const message = messages[i]!; + const tokens = estimateMessage(message); + if (tokens <= tailRemaining) { + tail.push(message); + tailRemaining -= tokens; + headEndExclusive = i; + continue; + } + const fullText = extractText(message.content); + const keptSuffix = truncateTextToTokensFromEnd(fullText, tailRemaining); + tail.push(replaceMessageText(message, keptSuffix)); + headEndExclusive = i; + const droppedPrefix = fullText.slice(0, fullText.length - keptSuffix.length); + if (droppedPrefix.length > 0) { + tailBoundaryDroppedPrefix = replaceMessageText(message, droppedPrefix); + } + break; + } + tail.reverse(); + + const headCandidates = messages.slice(0, headEndExclusive); + if (tailBoundaryDroppedPrefix !== null) { + headCandidates.push(tailBoundaryDroppedPrefix); + } + const head: T[] = []; + let headRemaining = headBudget; + for (const message of headCandidates) { + if (headRemaining <= 0) break; + const tokens = estimateMessage(message); + if (tokens <= headRemaining) { + head.push(message); + headRemaining -= tokens; + continue; + } + head.push(truncateUserMessage(message, headRemaining)); + break; + } + + let keptTokens = 0; + for (const message of head) keptTokens += estimateMessage(message); + for (const message of tail) keptTokens += estimateMessage(message); + return { head, tail, elided: true, omittedTokens: Math.max(0, totalTokens - keptTokens) }; +} + +function usesLegacyTailShape(input: ContextCompactionShapeInput): boolean { + return input.legacyTail === true; +} + +function extractText(content: readonly ContentPart[]): string { + let text = ''; + for (const part of content) { + if (part.type === 'text') { + text += part.text; + } + } + return text; +} + +function truncateTextToTokens(text: string, maxTokens: number): string { + if (maxTokens <= 0) return ''; + let asciiCount = 0; + let nonAsciiCount = 0; + let end = 0; + for (const char of text) { + if (char.codePointAt(0)! <= 127) { + asciiCount++; + } else { + nonAsciiCount++; + } + if (Math.ceil(asciiCount / 4) + nonAsciiCount > maxTokens) break; + end += char.length; + } + return text.slice(0, end); +} + +function truncateTextToTokensFromEnd(text: string, maxTokens: number): string { + if (maxTokens <= 0) return ''; + let asciiCount = 0; + let nonAsciiCount = 0; + let start = text.length; + for (let i = text.length - 1; i >= 0; i--) { + let isAscii = false; + const code = text.charCodeAt(i); + if (code >= 0xdc00 && code <= 0xdfff && i > 0) { + const high = text.charCodeAt(i - 1); + if (high >= 0xd800 && high <= 0xdbff) { + i--; + } + } else { + isAscii = code <= 127; + } + if (isAscii) { + asciiCount++; + } else { + nonAsciiCount++; + } + if (Math.ceil(asciiCount / 4) + nonAsciiCount > maxTokens) break; + start = i; + } + return text.slice(start); +} + +function replaceMessageText(message: T, text: string): T { + return { + ...message, + content: [{ type: 'text', text }], + toolCalls: [], + } as unknown as T; +} + +function truncateUserMessage(message: T, maxTokens: number): T { + return replaceMessageText(message, truncateTextToTokens(extractText(message.content), maxTokens)); +} diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextEvents.ts b/packages/agent-core-v2/src/agent/contextMemory/contextEvents.ts new file mode 100644 index 0000000000000000000000000000000000000000..3300d4ce6713995f5cae83c2c056f84528eecb20 --- /dev/null +++ b/packages/agent-core-v2/src/agent/contextMemory/contextEvents.ts @@ -0,0 +1,127 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { z } from 'zod'; + +import { AgentEvent2 } from '#/app/event/event2'; + +import type { LoopRecordedEvent } from './loopEventFold'; +import type { ContextMessage } from './types'; + +const contextMessageSchema = z.custom(); +const loopRecordedEventSchema = z.custom(); + +const contextAppendMessageSchema = z.object({ + agentId: z.string(), + message: contextMessageSchema, +}); + +export class ContextAppendMessage extends AgentEvent2< + z.infer +> { + static override readonly type = 'context.append_message'; + static override readonly durable = true; + static override readonly schema = contextAppendMessageSchema; +} +export interface ContextAppendMessage { + readonly agentId: string; + readonly message: ContextMessage; +} + +const contextAppendLoopEventSchema = z.object({ + agentId: z.string(), + event: loopRecordedEventSchema, +}); + +export class ContextAppendLoopEvent extends AgentEvent2< + z.infer +> { + static override readonly type = 'context.append_loop_event'; + static override readonly durable = true; + static override readonly schema = contextAppendLoopEventSchema; +} +export interface ContextAppendLoopEvent { + readonly agentId: string; + readonly event: LoopRecordedEvent; +} + +const contextClearSchema = z.object({ agentId: z.string() }); + +export class ContextClear extends AgentEvent2> { + static override readonly type = 'context.clear'; + static override readonly durable = true; + static override readonly schema = contextClearSchema; +} +export interface ContextClear { + readonly agentId: string; +} + +const contextCompactionBaseShape = { + agentId: z.string(), + tokensBefore: z.number().optional(), + tokensAfter: z.number().optional(), + summaryOutputTokens: z.number().optional(), + keptUserMessageCount: z.number().optional(), + keptHeadUserMessageCount: z.number().optional(), + droppedCount: z.number().optional(), + legacyTail: z.boolean().optional(), + wireLines: z + .object({ start: z.number().int().nonnegative(), end: z.number().int().nonnegative() }) + .optional(), +}; + +const contextApplyCompactionSchema = z.union([ + z.object({ + ...contextCompactionBaseShape, + summary: z.string(), + compactedCount: z.number(), + contextSummary: z.string().optional(), + }), + z.object({ + ...contextCompactionBaseShape, + contextSummary: z.string(), + compactedCount: z.number(), + summary: z.string().optional(), + }), + z.object({ + ...contextCompactionBaseShape, + summary: contextMessageSchema, + count: z.number(), + compactedCount: z.number().optional(), + }), +]); + +export type ContextApplyCompactionPayload = z.infer; + +export class ContextApplyCompaction extends AgentEvent2 { + static override readonly type = 'context.apply_compaction'; + static override readonly durable = true; + static override readonly schema = contextApplyCompactionSchema; +} + +const contextUndoSchema = z.object({ + agentId: z.string(), + count: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), +}); + +export class ContextUndo extends AgentEvent2> { + static override readonly type = 'context.undo'; + static override readonly durable = true; + static override readonly schema = contextUndoSchema; +} +export interface ContextUndo { + readonly agentId: string; + readonly count: number; +} + +export interface ContextSplicedPayload { + readonly agentId: string; + start: number; + deleteCount: number; + messages: readonly ContextMessage[]; + tokens?: number; +} + +export class ContextSpliced extends AgentEvent2 { + static override readonly type = 'context.spliced'; + static override readonly observable = true; +} +export interface ContextSpliced extends ContextSplicedPayload {} diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts new file mode 100644 index 0000000000000000000000000000000000000000..197c646f144f113d253f46a989b11babd5c11338 --- /dev/null +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts @@ -0,0 +1,48 @@ +import { createDecorator } from "#/_base/di/instantiation"; +import type { WireLineRange } from '#/wire/record'; + +import type { LoopRecordedEvent } from './loopEventFold'; +import type { ContextMessage } from './types'; + +export interface ContextCompactionInput { + readonly summary: string; + readonly contextSummary?: string; + readonly compactedCount: number; + readonly tokensBefore: number; + readonly tokensAfter?: number; + readonly summaryOutputTokens?: number; + readonly requestOverheadTokens?: number; + readonly keptUserMessageCount?: number; + readonly keptHeadUserMessageCount?: number; + readonly droppedCount?: number; + readonly wireLines?: WireLineRange; +} + +export interface ContextCompactionResult { + summary: string; + contextSummary: string; + compactedCount: number; + tokensBefore: number; + tokensAfter: number; + keptUserMessageCount: number; + keptHeadUserMessageCount?: number; + droppedCount?: number; +} + +export interface IAgentContextMemoryService { + readonly _serviceBrand: undefined; + + get(): readonly ContextMessage[]; + + append(...messages: readonly ContextMessage[]): void; + + appendLoopEvent(event: LoopRecordedEvent): void; + + publishTrailingRemoval(previous: readonly ContextMessage[]): boolean; + + clear(): void; + + applyCompaction(input: ContextCompactionInput): ContextCompactionResult; +} + +export const IAgentContextMemoryService = createDecorator('agentContextMemoryService'); diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts new file mode 100644 index 0000000000000000000000000000000000000000..7a818d934466f06cfc5f870dd9007f3f0bed1a6a --- /dev/null +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts @@ -0,0 +1,147 @@ +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { ISessionTokenCountingService } from '#/session/tokenCounting/sessionTokenCounting'; +import { IEventDispatcher } from '#/state/eventDispatcher'; + +import { + IAgentContextMemoryService, + type ContextCompactionInput, + type ContextCompactionResult, +} from './contextMemory'; +import { buildContextCompactionShape, type TokenEstimate } from './compactionHandoff'; +import { + ContextApplyCompaction, + ContextAppendLoopEvent, + ContextAppendMessage, + ContextClear, + ContextSpliced, + type ContextSplicedPayload, +} from './contextEvents'; +import { contextMemoryKey } from './contextOps'; +import type { LoopRecordedEvent } from './loopEventFold'; +import type { ContextMessage } from './types'; + +export class AgentContextMemoryService extends Disposable implements IAgentContextMemoryService { + declare readonly _serviceBrand: undefined; + + constructor( + @IEventDispatcher private readonly dispatcher: IEventDispatcher, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @ISessionTokenCountingService private readonly tokenCounting: ISessionTokenCountingService, + @IAgentStateService private readonly agentState: IAgentStateService, + ) { + super(); + this.agentState.contributeState(contextMemoryKey); + } + + private get tokenEstimateFns(): TokenEstimate { + return { + text: (text) => this.tokenCounting.estimateText(text), + message: (message) => this.tokenCounting.estimateMessage(message), + messages: (messages) => this.tokenCounting.estimateMessages(messages), + }; + } + + get(): readonly ContextMessage[] { + return this.agentState.get(contextMemoryKey) as readonly ContextMessage[]; + } + + append(...messages: readonly ContextMessage[]): void { + if (messages.length === 0) return; + const start = this.get().length; + for (const message of messages) { + void this.dispatcher.dispatch( + new ContextAppendMessage({ agentId: this.scopeContext.agentId, message }), + ); + } + this.publishSplice({ start, deleteCount: 0, messages: [...messages] }); + } + + appendLoopEvent(event: LoopRecordedEvent): void { + void this.dispatcher.dispatch( + new ContextAppendLoopEvent({ agentId: this.scopeContext.agentId, event }), + ); + } + + publishTrailingRemoval(previous: readonly ContextMessage[]): boolean { + const cutIndex = previous.length - 1; + if (cutIndex < 0) return false; + const current = this.get(); + if ( + current.length !== cutIndex || + current.some((message, index) => message !== previous[index]) + ) { + return false; + } + this.dispatchCutEvents(cutIndex); + this.publishSplice({ start: cutIndex, deleteCount: 1, messages: [] }); + return true; + } + + clear(): void { + const deleteCount = this.get().length; + if (deleteCount === 0) return; + void this.dispatcher.dispatch(new ContextClear({ agentId: this.scopeContext.agentId })); + this.tokenCounting.rebase(this.scopeContext.agentContext, { + length: 0, + tokens: 0, + measured: true, + }); + this.publishSplice({ start: 0, deleteCount, messages: [] }); + } + + applyCompaction(input: ContextCompactionInput): ContextCompactionResult { + const history = this.get(); + const result = buildContextCompactionShape(history, input, this.tokenEstimateFns); + void this.dispatcher.dispatch( + new ContextApplyCompaction({ + agentId: this.scopeContext.agentId, + summary: result.summary, + contextSummary: result.contextSummary, + compactedCount: result.compactedCount, + tokensBefore: result.tokensBefore, + tokensAfter: result.tokensAfter, + summaryOutputTokens: input.summaryOutputTokens, + keptUserMessageCount: result.keptUserMessageCount, + keptHeadUserMessageCount: result.keptHeadUserMessageCount, + droppedCount: result.droppedCount, + wireLines: input.wireLines, + }), + ); + this.tokenCounting.rebase(this.scopeContext.agentContext, { + length: result.messages.length, + tokens: result.tokensAfter, + measured: false, + }); + this.publishSplice({ + start: 0, + deleteCount: history.length, + messages: [...result.messages], + tokens: result.tokensAfter, + }); + const { messages: _messages, ...publicResult } = result; + void _messages; + return publicResult; + } + + private publishSplice(input: Omit): void { + void this.dispatcher.dispatch( + new ContextSpliced({ agentId: this.scopeContext.agentId, ...input }), + ); + } + + private dispatchCutEvents(cutIndex: number): void { + this.tokenCounting.recordTruncation(this.scopeContext.agentContext, cutIndex); + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentContextMemoryService, + AgentContextMemoryService, + ScopeActivation.OnScopeCreated, + 'contextMemory', +); diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts new file mode 100644 index 0000000000000000000000000000000000000000..f9c0850575851ec72d5db31f6cd77c8a6db2526c --- /dev/null +++ b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts @@ -0,0 +1,316 @@ +import { z } from 'zod'; + +import { ErrorCodes, Error2 } from '#/errors'; +import type { ContentPart } from '#human/llm/message'; +import { defineState } from '#/state/state'; +import type { PartsTransformer } from '#/wire/record'; +import type { WireRecord } from '#/wire/record'; + +import { + buildContextCompactionShape, + createCompactionSummaryMessage, + type ContextCompactionShapeInput, +} from './compactionHandoff'; +import { + ContextAppendLoopEvent, + ContextAppendMessage, + ContextApplyCompaction, + ContextClear, + type ContextApplyCompactionPayload, +} from './contextEvents'; +import { isPromptOwnedInjection, isUndoAnchor } from './conversationTime'; +import { + foldAppendMessage, + foldLoopEvent, + resetFold, + type LoopRecordedEvent, +} from './loopEventFold'; +import type { ContextMessage } from './types'; + +async function dehydrateMessages( + messages: readonly ContextMessage[], + transform: PartsTransformer, +): Promise<{ changed: boolean; result: ContextMessage[] }> { + let changed = false; + const result: ContextMessage[] = []; + for (const msg of messages) { + const parts = await transform(msg.content); + if (parts !== msg.content) { + changed = true; + result.push({ ...msg, content: [...parts] as ContentPart[] }); + } else { + result.push(msg); + } + } + return { changed, result }; +} + +async function dehydrateRecord( + record: WireRecord, + transform: PartsTransformer, +): Promise { + if (record.type === 'context.append_message') { + const message = record['message'] as ContextMessage | undefined; + if (message === undefined) return record; + const parts = await transform(message.content); + if (parts === message.content) return record; + return { ...record, message: { ...message, content: [...parts] } }; + } + if (record.type === 'context.append_loop_event') { + const event = record['event'] as LoopRecordedEvent | undefined; + if (event === undefined) return record; + if (event.type === 'content.part') { + const parts = await transform([event.part]); + if (parts[0] === event.part) return record; + return { ...record, event: { ...event, part: parts[0] } }; + } + if (event.type === 'tool.result') { + const output = event.result.output; + if (!Array.isArray(output)) return record; + const parts = await transform(output); + if (parts === output) return record; + return { ...record, event: { ...event, result: { ...event.result, output: [...parts] } } }; + } + return record; + } + return record; +} + +export const contextMemoryKey = defineState('contextMemory', (): ContextMessage[] => []) + .replayable({ + schema: z.custom(), + blobs: { + dehydrate: dehydrateRecord, + rehydrate: async (state, transform) => { + const { changed, result } = await dehydrateMessages(state, transform); + return changed ? result : state; + }, + }, + }) + .undoable({ + onUndo: (s, count) => { + if (s.length === 0) return; + const cut = computeUndoCut(s, count); + if (!isFullyUndoable(cut, count)) return; + return resetFold(s.slice(0, cut.cutIndex)) as ContextMessage[]; + }, + }) + .on(ContextAppendMessage, (s, e) => foldAppendMessage(s, e.message) as ContextMessage[]) + .on(ContextAppendLoopEvent, (s, e) => foldLoopEvent(s, e.event) as ContextMessage[]) + .on(ContextClear, (s) => (s.length === 0 ? undefined : (resetFold([]) as ContextMessage[]))) + .on(ContextApplyCompaction, (s, e) => { + const result = buildContextCompactionShape( + s, + readContextCompactionShapeInput(e as unknown as ContextApplyCompactionPayload), + ); + return resetFold([...result.messages]) as ContextMessage[]; + }); + +export function popSwarmModeReminder(state: ContextMessage[]): ContextMessage[] { + const last = state.at(-1); + if (last?.origin?.kind !== 'injection' || last.origin.variant !== 'swarm_mode') return state; + return resetFold(state.slice(0, -1)) as ContextMessage[]; +} + +interface UnknownRecord { + readonly [key: string]: unknown; +} + +type ContextCompactionRecord = ContextApplyCompactionPayload | UnknownRecord; + +export function applyContextCompactionRecord( + state: readonly ContextMessage[], + record: ContextCompactionRecord, +): ContextMessage[] { + const result = buildContextCompactionShape(state, readContextCompactionShapeInput(record)); + return resetFold([...result.messages]) as ContextMessage[]; +} + +export function readContextCompactionShapeInput( + record: ContextCompactionRecord, +): ContextCompactionShapeInput { + const fields = record as UnknownRecord; + const keptUserMessageCount = readOptionalNumber(fields, 'keptUserMessageCount'); + return { + summary: readContextCompactionRawSummary(fields), + legacySummaryMessage: readLegacySummaryMessage(fields), + contextSummary: readOptionalString(fields, 'contextSummary'), + compactedCount: readContextCompactedCount(fields), + tokensBefore: readOptionalNumber(fields, 'tokensBefore') ?? 0, + tokensAfter: readOptionalNumber(fields, 'tokensAfter'), + summaryOutputTokens: readOptionalNumber(fields, 'summaryOutputTokens'), + keptUserMessageCount, + keptHeadUserMessageCount: readOptionalNumber(fields, 'keptHeadUserMessageCount'), + droppedCount: readOptionalNumber(fields, 'droppedCount'), + legacyTail: readOptionalBoolean(fields, 'legacyTail') ?? keptUserMessageCount === undefined, + }; +} + +export function readContextCompactedCount(record: ContextCompactionRecord): number { + const fields = record as UnknownRecord; + const compactedCount = fields['compactedCount']; + if (typeof compactedCount === 'number') return compactedCount; + const legacyCount = fields['count']; + if (typeof legacyCount === 'number') return legacyCount; + throw new Error2( + ErrorCodes.STORAGE_DECODE_FAILED, + 'Invalid context.apply_compaction record: missing compactedCount', + { + details: { + recordKeys: Object.keys(record), + compactedCountType: typeof compactedCount, + countType: typeof legacyCount, + }, + }, + ); +} + +export function readContextCompactionSummary(record: ContextCompactionRecord): ContextMessage { + const fields = record as UnknownRecord; + const contextSummary = fields['contextSummary']; + if (typeof contextSummary === 'string') return createCompactionSummaryMessage(contextSummary); + const summary = fields['summary']; + if (typeof summary === 'string') return createCompactionSummaryMessage(summary); + if (isContextMessage(summary)) return summary; + throw new Error2( + ErrorCodes.STORAGE_DECODE_FAILED, + 'Invalid context.apply_compaction record: missing summary', + { + details: { + recordKeys: Object.keys(record), + summaryType: typeof summary, + contextSummaryType: typeof contextSummary, + }, + }, + ); +} + +function readContextCompactionRawSummary(record: UnknownRecord): string { + const summary = record['summary']; + if (typeof summary === 'string') return summary; + const contextSummary = record['contextSummary']; + if (typeof contextSummary === 'string') return contextSummary; + if (isContextMessage(summary)) { + return textOf(summary); + } + throw new Error2( + ErrorCodes.STORAGE_DECODE_FAILED, + 'Invalid context.apply_compaction record: missing summary', + { + details: { + recordKeys: Object.keys(record), + summaryType: typeof summary, + contextSummaryType: typeof contextSummary, + }, + }, + ); +} + +function readLegacySummaryMessage(record: UnknownRecord): ContextMessage | undefined { + const summary = record['summary']; + return isContextMessage(summary) ? summary : undefined; +} + +function readOptionalNumber(record: UnknownRecord, key: string): number | undefined { + const value = record[key]; + return typeof value === 'number' ? value : undefined; +} + +function readOptionalString(record: UnknownRecord, key: string): string | undefined { + const value = record[key]; + return typeof value === 'string' ? value : undefined; +} + +function readOptionalBoolean(record: UnknownRecord, key: string): boolean | undefined { + const value = record[key]; + return typeof value === 'boolean' ? value : undefined; +} + +function textOf(message: ContextMessage): string { + let text = ''; + for (const part of message.content) { + if (part.type === 'text') text += part.text; + } + return text; +} + +function isContextMessage(value: unknown): value is ContextMessage { + if (value === null || typeof value !== 'object') return false; + const message = value as { role?: unknown; content?: unknown }; + return typeof message.role === 'string' && Array.isArray(message.content); +} + +export interface UndoCut { + readonly cutIndex: number; + readonly removedCount: number; + readonly stoppedAtCompaction: boolean; +} + +export function computeUndoCut(state: readonly ContextMessage[], count: number): UndoCut { + let remaining = count; + let cutIndex = -1; + let removedCount = 0; + let stoppedAtCompaction = false; + for (let i = state.length - 1; i >= 0 && remaining > 0; i--) { + const message = state[i]; + if (message === undefined || message.origin?.kind === 'injection') continue; + if (message.origin?.kind === 'compaction_summary') { + stoppedAtCompaction = true; + break; + } + if (isUndoAnchor(message)) { + remaining--; + removedCount++; + cutIndex = i; + while ( + cutIndex > 0 && + isPromptOwnedInjection(state[cutIndex - 1]!, message) + ) { + cutIndex--; + } + } + } + return { cutIndex, removedCount, stoppedAtCompaction }; +} + +export function isFullyUndoable(cut: UndoCut, count: number): boolean { + return cut.cutIndex >= 0 && cut.removedCount >= count; +} + +export type UndoUnavailableReason = + | 'empty' + | 'compaction_boundary' + | 'insufficient'; + +export type UndoPrecheck = + | { readonly ok: true } + | { + readonly ok: false; + readonly reason: UndoUnavailableReason; + readonly requested: number; + readonly undoable: number; + }; + +export function precheckUndo(history: readonly ContextMessage[], count: number): UndoPrecheck { + const cut = computeUndoCut(history, count); + if (isFullyUndoable(cut, count)) return { ok: true }; + const reason: UndoUnavailableReason = cut.stoppedAtCompaction + ? 'compaction_boundary' + : cut.removedCount === 0 + ? 'empty' + : 'insufficient'; + return { ok: false, reason, requested: count, undoable: cut.removedCount }; +} + +export function formatUndoUnavailableMessage( + precheck: Extract, +): string { + switch (precheck.reason) { + case 'empty': + return 'Nothing to undo: no user message to undo'; + case 'compaction_boundary': + return 'Nothing to undo: would cross a compaction boundary'; + case 'insufficient': + return `Nothing to undo: only ${precheck.undoable} of ${precheck.requested} requested turn(s) available`; + } +} diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts new file mode 100644 index 0000000000000000000000000000000000000000..5f7fd4cee153da43a64fbb499bb0c12be3baccae --- /dev/null +++ b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts @@ -0,0 +1,231 @@ +import { type ContentPart, type ToolCall } from '#human/llm/message'; +import type { WireRecord } from '#/wire/record'; + +import { + COMPACT_USER_MESSAGE_MAX_TOKENS, + collectCompactableUserMessages, + selectRecentUserMessages, +} from './compactionHandoff'; +import { isPromptOwnedInjection, isUndoAnchor } from './conversationTime'; +import { createLoopEventFold, type LoopRecordedEvent } from './loopEventFold'; +import type { ContextMessage } from './types'; + +export interface ContextTranscript { + readonly entries: readonly ContextMessage[]; + readonly times: readonly (number | undefined)[]; + readonly foldedLength: number; +} + +export interface ContextTranscriptReducer { + add(record: WireRecord): void; + result(): ContextTranscript; +} + +interface MutableMessage { + id?: string; + role: ContextMessage['role']; + content: ContentPart[]; + toolCalls: ToolCall[]; + toolCallId?: string; + isError?: boolean; + note?: string; + origin?: ContextMessage['origin']; +} + +interface MutableEntry { + message: MutableMessage; + time?: number; +} + +export function reduceContextTranscript(records: Iterable): ContextTranscript { + const reducer = createContextTranscriptReducer(); + for (const record of records) reducer.add(record); + return reducer.result(); +} + +export function createContextTranscriptReducer(): ContextTranscriptReducer { + const transcript: MutableEntry[] = []; + let foldedLength = 0; + let clearFloor = 0; + let openEntry: MutableEntry | undefined; + + const push = (...entries: MutableEntry[]): void => { + transcript.push(...entries); + foldedLength += entries.length; + }; + + const fold = createLoopEventFold({ + openAssistant: (time) => { + openEntry = { message: { role: 'assistant', content: [], toolCalls: [] }, time }; + push(openEntry); + }, + appendOpenContent: (part) => { + openEntry?.message.content.push(part); + }, + appendOpenToolCall: (call) => { + openEntry?.message.toolCalls.push(call); + }, + dropOpenAssistant: () => { + if (openEntry === undefined) return; + const index = transcript.indexOf(openEntry); + openEntry = undefined; + if (index === -1) return; + transcript.splice(index, 1); + foldedLength = Math.max(0, foldedLength - 1); + }, + sealOpenAssistant: () => { + openEntry = undefined; + }, + pushToolMessage: (message, time) => { + push({ message: message as MutableMessage, time }); + }, + pushMessage: (message, time) => { + push(toMutableEntry(message, time)); + }, + }); + + const resetOpenState = (): void => { + fold.reset(); + openEntry = undefined; + }; + + const applyUndo = (count: number): void => { + if (count <= 0) return; + let removedUserCount = 0; + for (let i = transcript.length - 1; i >= clearFloor; i--) { + const message = transcript[i]!.message; + if (message.origin?.kind === 'injection') continue; + if (message.origin?.kind === 'compaction_summary') break; + transcript.splice(i, 1); + foldedLength = Math.max(0, foldedLength - 1); + if (isUndoAnchor(message)) { + removedUserCount++; + while ( + i > clearFloor && + isPromptOwnedInjection(transcript[i - 1]!.message, message) + ) { + transcript.splice(i - 1, 1); + i--; + foldedLength = Math.max(0, foldedLength - 1); + } + if (removedUserCount >= count) break; + } + } + resetOpenState(); + }; + + const add = (record: WireRecord): void => { + switch (record.type) { + case 'context.append_message': { + fold.appendMessage(record['message'] as ContextMessage, record.time); + break; + } + case 'context.append_loop_event': { + fold.loopEvent(record['event'] as LoopRecordedEvent, record.time); + break; + } + case 'context.apply_compaction': { + if (readNumber(record, 'keptUserMessageCount') !== undefined) { + fold.settle(record.time); + } else { + resetOpenState(); + } + transcript.push({ + message: { + role: 'user', + content: [{ type: 'text', text: readCompactionSummaryText(record) }], + toolCalls: [], + origin: { kind: 'compaction_summary' }, + }, + time: record.time, + }); + foldedLength = recoverFoldedLength(record, transcript, clearFloor, foldedLength); + break; + } + case 'context.undo': + applyUndo(record['count'] as number); + break; + case 'context.clear': + clearFloor = transcript.length; + foldedLength = 0; + resetOpenState(); + break; + default: + break; + } + }; + + return { + add, + result: () => ({ + entries: transcript.map((e) => e.message), + times: transcript.map((e) => e.time), + foldedLength, + }), + }; +} + +function toMutableEntry(message: ContextMessage, time: number | undefined): MutableEntry { + return { + message: { + ...(message.id !== undefined ? { id: message.id } : {}), + role: message.role, + content: [...message.content], + toolCalls: [...message.toolCalls], + ...(message.toolCallId !== undefined ? { toolCallId: message.toolCallId } : {}), + ...(message.isError !== undefined ? { isError: message.isError } : {}), + ...(message.origin !== undefined ? { origin: message.origin } : {}), + }, + time, + }; +} + +function recoverFoldedLength( + record: WireRecord, + transcript: readonly MutableEntry[], + clearFloor: number, + foldedLength: number, +): number { + const keptUserMessageCount = readNumber(record, 'keptUserMessageCount'); + const keptHeadUserMessageCount = readNumber(record, 'keptHeadUserMessageCount'); + const compactedCount = readNumber(record, 'compactedCount'); + if (keptUserMessageCount !== undefined) { + return keptUserMessageCount + (keptHeadUserMessageCount === undefined ? 2 : 3); + } + if (compactedCount !== undefined && compactedCount < foldedLength) { + return 1 + (foldedLength - compactedCount); + } + const keptUserMessages = selectRecentUserMessages( + collectCompactableUserMessages(transcript.slice(clearFloor).map((e) => e.message)), + COMPACT_USER_MESSAGE_MAX_TOKENS, + ); + return keptUserMessages.length + 1; +} + +function readCompactionSummaryText(record: WireRecord): string { + const summary = record['summary']; + if (typeof summary === 'string') return summary; + const contextSummary = record['contextSummary']; + if (typeof contextSummary === 'string') return contextSummary; + if (isContextMessageLike(summary)) return textOfParts(summary.content); + return ''; +} + +function isContextMessageLike(value: unknown): value is ContextMessage { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const message = value as { role?: unknown; content?: unknown }; + return typeof message.role === 'string' && Array.isArray(message.content); +} + +function textOfParts(content: readonly ContentPart[]): string { + let text = ''; + for (const part of content) { + if (part.type === 'text') text += part.text; + } + return text; +} + +function readNumber(record: WireRecord, key: string): number | undefined { + const value = record[key]; + return typeof value === 'number' ? value : undefined; +} diff --git a/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts b/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts new file mode 100644 index 0000000000000000000000000000000000000000..b7275a15a51a666dae75f1f4bb826b0f4ca5f723 --- /dev/null +++ b/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts @@ -0,0 +1,49 @@ +import { registerUndoableProtocol } from '#/state/state'; + +import { + ContextAppendMessage, + ContextApplyCompaction, + ContextClear, + ContextUndo, +} from './contextEvents'; +import type { ContextMessage } from './types'; + +export function isUndoAnchorOrigin(origin: ContextMessage['origin']): boolean { + if (origin === undefined || origin.kind === 'user') return true; + return ( + (origin.kind === 'skill_activation' || origin.kind === 'plugin_command') && + origin.trigger === 'user-slash' + ); +} + +export function isUndoAnchor(message: ContextMessage): boolean { + if (message.role !== 'user') return false; + return isUndoAnchorOrigin(message.origin); +} + +export function isPromptOwnedInjection( + message: ContextMessage, + prompt: ContextMessage, +): boolean { + const origin = message.origin; + return ( + origin?.kind === 'injection' && + origin.ownerPromptId !== undefined && + origin.ownerPromptId === prompt.id + ); +} + +export function isValidUndoCount(count: number): boolean { + return Number.isSafeInteger(count) && count > 0; +} + +registerUndoableProtocol({ + events: { + appendMessage: ContextAppendMessage, + applyCompaction: ContextApplyCompaction, + clear: ContextClear, + undo: ContextUndo, + }, + isUndoAnchor: (message) => isUndoAnchor(message as ContextMessage), + isValidUndoCount, +}); diff --git a/packages/agent-core-v2/src/agent/contextMemory/conversationUndoParticipants.ts b/packages/agent-core-v2/src/agent/contextMemory/conversationUndoParticipants.ts new file mode 100644 index 0000000000000000000000000000000000000000..66a0fccc23dba8ae24954ba77b4b8a0dd0409971 --- /dev/null +++ b/packages/agent-core-v2/src/agent/contextMemory/conversationUndoParticipants.ts @@ -0,0 +1,59 @@ +import { createDecorator } from '#/_base/di/instantiation'; +import { toDisposable, type IDisposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { BugIndicatingError } from '#/errors'; + +export interface AgentConversationUndoParticipant { + readonly id: string; + readonly phase?: 'after-flush'; + reconcileAfterUndo(): Promise; +} + +export interface IAgentConversationUndoParticipantRegistry { + readonly _serviceBrand: undefined; + + register(participant: AgentConversationUndoParticipant): IDisposable; + list(): readonly AgentConversationUndoParticipant[]; +} + +export const IAgentConversationUndoParticipantRegistry = + createDecorator( + 'agentConversationUndoParticipantRegistry', + ); + +class AgentConversationUndoParticipantRegistry + extends Service + implements IAgentConversationUndoParticipantRegistry +{ + declare readonly _serviceBrand: undefined; + + private readonly participants = new Map(); + + register(participant: AgentConversationUndoParticipant): IDisposable { + if (this.participants.has(participant.id)) { + throw new BugIndicatingError( + `Conversation undo participant "${participant.id}" is already registered`, + ); + } + this.participants.set(participant.id, participant); + return toDisposable(() => { + if (this.participants.get(participant.id) === participant) { + this.participants.delete(participant.id); + } + }); + } + + list(): readonly AgentConversationUndoParticipant[] { + return [...this.participants.values()]; + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentConversationUndoParticipantRegistry, + AgentConversationUndoParticipantRegistry, + ScopeActivation.OnScopeCreated, + 'conversationUndoParticipants', +); diff --git a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts new file mode 100644 index 0000000000000000000000000000000000000000..c805c0be2a341dc35719275b9aa50ae274041cb0 --- /dev/null +++ b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts @@ -0,0 +1,352 @@ +import { isDraft, original } from 'immer'; + +import type { FinishReason } from '#human/llm/finish-reason'; +import { createToolMessage } from '#/llm-adapter/contract/message'; +import type { ContentPart, ToolCall } from '#human/llm/message'; +import type { TokenUsage } from '#human/llm/usage'; +import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; + +import type { ContextMessage } from './types'; +import { isVacuousContentPart } from './vacuousContent'; + +const TOOL_INTERRUPTED_ON_RESUME_OUTPUT = + 'Tool execution was interrupted before its result was recorded. Do not assume the tool completed successfully.'; + +export type LoopRecordedEvent = + | { + readonly type: 'step.begin'; + readonly uuid: string; + readonly turnId?: string; + readonly step?: number; + } + | { + readonly type: 'step.end'; + readonly uuid: string; + readonly turnId?: string; + readonly step?: number; + readonly finishReason?: string; + readonly usage?: TokenUsage; + readonly llmFirstTokenLatencyMs?: number; + readonly llmStreamDurationMs?: number; + readonly llmRequestBuildMs?: number; + readonly llmServerFirstTokenMs?: number; + readonly llmServerDecodeMs?: number; + readonly llmClientConsumeMs?: number; + readonly llmClientBlockedMs?: number; + readonly messageId?: string; + readonly providerFinishReason?: FinishReason; + readonly rawFinishReason?: string; + } + | { + readonly type: 'content.part'; + readonly stepUuid: string; + readonly part: ContentPart; + readonly uuid?: string; + readonly turnId?: string; + readonly step?: number; + } + | { + readonly type: 'tool.call'; + readonly stepUuid: string; + readonly toolCallId: string; + readonly name: string; + readonly args?: unknown; + readonly extras?: Record; + readonly display?: ToolInputDisplay; + readonly uuid?: string; + readonly turnId?: string; + readonly step?: number; + } + | { + readonly type: 'tool.result'; + readonly toolCallId: string; + readonly result: { + readonly output: string | readonly ContentPart[]; + readonly isError?: boolean; + readonly note?: string; + }; + readonly parentUuid?: string; + }; + +export interface LoopEventFoldSink { + openAssistant(time: number | undefined): void; + appendOpenContent(part: ContentPart): void; + appendOpenToolCall(call: ToolCall, display?: ToolInputDisplay): void; + dropOpenAssistant(): void; + sealOpenAssistant(): void; + pushToolMessage(message: ContextMessage, time: number | undefined): void; + pushMessage(message: ContextMessage, time: number | undefined): void; +} + +export interface LoopEventFold { + appendMessage(message: ContextMessage, time?: number): void; + loopEvent(event: LoopRecordedEvent, time?: number): void; + settle(time?: number): void; + reset(): void; +} + +export function createLoopEventFold(sink: LoopEventFoldSink): LoopEventFold { + return createLoopEventFoldWithState(sink); +} + +interface InitialFoldState { + readonly openHasToolCalls: boolean; + readonly openVacuous: boolean; + readonly pendingToolCallIds: readonly string[]; +} + +function createLoopEventFoldWithState( + sink: LoopEventFoldSink, + initial?: InitialFoldState, +): LoopEventFold { + let openStepUuid: string | null | undefined = initial === undefined ? undefined : null; + let openHasToolCalls = initial?.openHasToolCalls ?? false; + let openVacuous = initial?.openVacuous ?? true; + const pending = new Set(initial?.pendingToolCallIds); + let deferred: { message: ContextMessage; time: number | undefined }[] = []; + + const flushDeferred = (): void => { + if (pending.size > 0 || deferred.length === 0) return; + for (const entry of deferred) sink.pushMessage(entry.message, entry.time); + deferred = []; + }; + const closePending = (time: number | undefined): void => { + if (pending.size === 0) return; + for (const toolCallId of pending) { + sink.pushToolMessage(interruptedToolMessage(toolCallId), time); + } + pending.clear(); + flushDeferred(); + }; + const settleOpen = (time: number | undefined): void => { + if (openStepUuid === undefined) return; + closePending(time); + if (!openHasToolCalls && openVacuous) { + sink.dropOpenAssistant(); + } else { + sink.sealOpenAssistant(); + } + openStepUuid = undefined; + }; + const acceptsOpenStep = (stepUuid: string): boolean => { + if (openStepUuid === undefined) return false; + if (openStepUuid === null) { + openStepUuid = stepUuid; + return true; + } + return stepUuid === openStepUuid; + }; + + return { + appendMessage(message, time) { + if (pending.size > 0) { + deferred.push({ message, time }); + return; + } + sink.pushMessage(message, time); + }, + loopEvent(event, time) { + switch (event.type) { + case 'step.begin': { + settleOpen(time); + sink.openAssistant(time); + openStepUuid = event.uuid; + openHasToolCalls = false; + openVacuous = true; + return; + } + case 'step.end': { + if (event.finishReason === 'interrupted' || event.finishReason === 'error') return; + settleOpen(time); + flushDeferred(); + return; + } + case 'content.part': { + if (!acceptsOpenStep(event.stepUuid)) return; + sink.appendOpenContent(event.part); + openVacuous = openVacuous && isVacuousContentPart(event.part); + return; + } + case 'tool.call': { + if (!acceptsOpenStep(event.stepUuid)) return; + const call: ToolCall = { + type: 'function', + id: event.toolCallId, + name: event.name, + arguments: event.args === undefined ? null : JSON.stringify(event.args), + ...(event.extras !== undefined ? { extras: event.extras } : {}), + }; + sink.appendOpenToolCall(call, event.display); + pending.add(event.toolCallId); + openHasToolCalls = true; + return; + } + case 'tool.result': { + if (!pending.has(event.toolCallId)) return; + pending.delete(event.toolCallId); + const output = event.result.output; + sink.pushToolMessage( + { + ...createToolMessage( + event.toolCallId, + typeof output === 'string' ? output : [...output], + ), + isError: event.result.isError, + note: event.result.note, + }, + time, + ); + flushDeferred(); + return; + } + } + }, + settle(time) { + settleOpen(time); + flushDeferred(); + }, + reset() { + openStepUuid = undefined; + openHasToolCalls = false; + openVacuous = true; + pending.clear(); + deferred = []; + }, + }; +} + +interface ImmutableFoldSink extends LoopEventFoldSink { + current(): readonly ContextMessage[]; +} + +interface BoundFold { + readonly fold: LoopEventFold; + readonly sink: ImmutableFoldSink; +} + +const boundFoldMap = new WeakMap(); + +export function foldAppendMessage( + state: readonly ContextMessage[], + message: ContextMessage, +): readonly ContextMessage[] { + const bound = boundOf(state); + bound.fold.appendMessage(message, undefined); + return bind(bound, bound.sink.current()); +} + +export function foldLoopEvent( + state: readonly ContextMessage[], + event: LoopRecordedEvent, +): readonly ContextMessage[] { + const bound = boundOf(state); + bound.fold.loopEvent(event, undefined); + return bind(bound, bound.sink.current()); +} + +export function resetFold(state: readonly ContextMessage[]): readonly ContextMessage[] { + const sink = createImmutableFoldSink(state); + boundFoldMap.set(state, { fold: createLoopEventFold(sink), sink }); + return state; +} + +function boundOf(state: readonly ContextMessage[]): BoundFold { + const key = keyOf(state); + let bound = boundFoldMap.get(key); + if (bound === undefined || bound.sink.current() !== key) { + const sink = createImmutableFoldSink(key); + bound = { fold: createLoopEventFoldWithState(sink, recoverFoldState(key)), sink }; + boundFoldMap.set(key, bound); + } + return bound; +} + +function bind(bound: BoundFold, state: readonly ContextMessage[]): readonly ContextMessage[] { + boundFoldMap.set(state, bound); + return state; +} + +function keyOf(state: readonly ContextMessage[]): readonly ContextMessage[] { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (isDraft(state) ? original(state as any) : state) as readonly ContextMessage[]; +} + +function createImmutableFoldSink(initial: readonly ContextMessage[]): ImmutableFoldSink { + let current = initial; + let openIndex = findOpenAssistantIndex(initial); + const updateOpen = (update: (message: ContextMessage) => ContextMessage): void => { + if (openIndex === -1) return; + const next = current.slice(); + next[openIndex] = update(next[openIndex]!); + current = next; + }; + return { + current: () => current, + openAssistant: () => { + current = [...current, { role: 'assistant', content: [], toolCalls: [], partial: true }]; + openIndex = current.length - 1; + }, + appendOpenContent: (part) => { + updateOpen((message) => ({ ...message, content: [...message.content, part] })); + }, + appendOpenToolCall: (call, display) => { + updateOpen((message) => ({ + ...message, + toolCalls: [...message.toolCalls, call], + toolCallDisplays: + display === undefined + ? message.toolCallDisplays + : { ...message.toolCallDisplays, [call.id]: display }, + })); + }, + dropOpenAssistant: () => { + if (openIndex === -1) return; + current = [...current.slice(0, openIndex), ...current.slice(openIndex + 1)]; + openIndex = -1; + }, + sealOpenAssistant: () => { + updateOpen((message) => ({ ...message, partial: undefined })); + openIndex = -1; + }, + pushToolMessage: (message) => { + current = [...current, message]; + }, + pushMessage: (message) => { + current = [...current, message]; + }, + }; +} + +function findOpenAssistantIndex(state: readonly ContextMessage[]): number { + for (let i = state.length - 1; i >= 0; i--) { + if (state[i]!.partial === true) return i; + } + return -1; +} + +function recoverFoldState(state: readonly ContextMessage[]): InitialFoldState | undefined { + const openIndex = findOpenAssistantIndex(state); + if (openIndex === -1) return undefined; + const open = state[openIndex]!; + const resolvedToolCallIds = new Set(); + for (let i = openIndex + 1; i < state.length; i++) { + const message = state[i]!; + if (message.role === 'tool' && message.toolCallId !== undefined) { + resolvedToolCallIds.add(message.toolCallId); + } + } + return { + openHasToolCalls: open.toolCalls.length > 0, + openVacuous: open.content.every(isVacuousContentPart), + pendingToolCallIds: open.toolCalls + .map((call) => call.id) + .filter((toolCallId) => !resolvedToolCallIds.has(toolCallId)), + }; +} + +function interruptedToolMessage(toolCallId: string): ContextMessage { + return { + ...createToolMessage(toolCallId, TOOL_INTERRUPTED_ON_RESUME_OUTPUT), + isError: true, + }; +} diff --git a/packages/agent-core-v2/src/agent/contextMemory/messageId.ts b/packages/agent-core-v2/src/agent/contextMemory/messageId.ts new file mode 100644 index 0000000000000000000000000000000000000000..6518caed92a2d2f07f8ef7c95a7654d3035704aa --- /dev/null +++ b/packages/agent-core-v2/src/agent/contextMemory/messageId.ts @@ -0,0 +1,5 @@ +import { ulid } from 'ulid'; + +export function newMessageId(): string { + return `msg_${ulid()}`; +} diff --git a/packages/agent-core-v2/src/agent/contextMemory/openToolExchange.ts b/packages/agent-core-v2/src/agent/contextMemory/openToolExchange.ts new file mode 100644 index 0000000000000000000000000000000000000000..a36d6e7d99fc8cad0669998d441adeed1515fe81 --- /dev/null +++ b/packages/agent-core-v2/src/agent/contextMemory/openToolExchange.ts @@ -0,0 +1,40 @@ +import { createToolMessage } from '#/llm-adapter/contract/message'; + +import type { ContextMessage } from './types'; + +export const INHERITED_IN_FLIGHT_TOOL_OUTPUT = + 'This tool call was still executing when this conversation snapshot was inherited from the source agent, so its result is not part of this context. The outcome is unknown — do not assume it succeeded or failed, and do not wait for it.'; + +export function closeTrailingOpenToolExchange( + history: readonly ContextMessage[], +): ContextMessage[] { + let lastNonToolIndex = history.length - 1; + while (lastNonToolIndex >= 0 && history[lastNonToolIndex]?.role === 'tool') { + lastNonToolIndex -= 1; + } + + const assistant = history[lastNonToolIndex]; + if (assistant === undefined) return []; + if (assistant.role !== 'assistant' || assistant.toolCalls.length === 0) return [...history]; + + const answeredToolCallIds = new Set( + history + .slice(lastNonToolIndex + 1) + .map((message) => message.toolCallId) + .filter((toolCallId): toolCallId is string => typeof toolCallId === 'string'), + ); + const openCalls = assistant.toolCalls.filter( + (toolCall) => !answeredToolCallIds.has(toolCall.id), + ); + if (openCalls.length === 0) return [...history]; + const settledAssistant = + assistant.partial === true ? { ...assistant, partial: undefined } : assistant; + return [ + ...history.slice(0, lastNonToolIndex), + settledAssistant, + ...history.slice(lastNonToolIndex + 1), + ...openCalls.map((toolCall) => + createToolMessage(toolCall.id, INHERITED_IN_FLIGHT_TOOL_OUTPUT), + ), + ]; +} diff --git a/packages/agent-core-v2/src/agent/contextMemory/toolResultRender.ts b/packages/agent-core-v2/src/agent/contextMemory/toolResultRender.ts new file mode 100644 index 0000000000000000000000000000000000000000..c7bad66c0d3bb98ba47e618cfc0704bd1f61b76c --- /dev/null +++ b/packages/agent-core-v2/src/agent/contextMemory/toolResultRender.ts @@ -0,0 +1,59 @@ +import type { ContentPart } from '#human/llm/message'; + +const TOOL_ERROR_STATUS = 'ERROR: Tool execution failed.'; +const TOOL_EMPTY_STATUS = 'Tool output is empty.'; +const TOOL_EMPTY_ERROR_STATUS = + 'ERROR: Tool execution failed. Tool output is empty.'; +const TOOL_OUTPUT_EMPTY_TEXT = 'Tool output is empty.'; + +export interface RenderableToolResult { + readonly output: string | readonly ContentPart[]; + readonly note?: string; + readonly isError?: boolean; +} + +export function renderToolResultForModel(result: RenderableToolResult): ContentPart[] { + const rendered = renderStatus(result); + if (result.note === undefined || result.note.length === 0) return rendered; + const only = rendered[0]; + if (rendered.length === 1 && only?.type === 'text') { + return [textPart(only.text + '\n' + result.note)]; + } + return [...rendered, textPart(result.note)]; +} + +function renderStatus(result: RenderableToolResult): ContentPart[] { + const output = result.output; + const single = typeof output === 'string' ? output : singleTextPart(output); + if (single !== undefined) { + if (result.isError === true) { + if (single.length === 0) return [textPart(TOOL_EMPTY_ERROR_STATUS)]; + return [textPart(TOOL_ERROR_STATUS + '\n' + single)]; + } + return isEmptyOutputText(single) ? [textPart(TOOL_EMPTY_STATUS)] : [textPart(single)]; + } + + const parts = output as readonly ContentPart[]; + if (isEmptyEquivalentContentArray(parts)) { + return [textPart(result.isError === true ? TOOL_EMPTY_ERROR_STATUS : TOOL_EMPTY_STATUS)]; + } + if (result.isError === true) return [textPart(TOOL_ERROR_STATUS), ...parts]; + return [...parts]; +} + +function singleTextPart(output: readonly ContentPart[]): string | undefined { + const first = output[0]; + return output.length === 1 && first?.type === 'text' ? first.text : undefined; +} + +function textPart(text: string): ContentPart { + return { type: 'text', text }; +} + +function isEmptyOutputText(output: string): boolean { + return output.trim().length === 0 || output.trim() === TOOL_OUTPUT_EMPTY_TEXT; +} + +function isEmptyEquivalentContentArray(output: readonly ContentPart[]): boolean { + return output.every((part) => part.type === 'text' && part.text.trim().length === 0); +} diff --git a/packages/agent-core-v2/src/agent/contextMemory/types.ts b/packages/agent-core-v2/src/agent/contextMemory/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..6b6ba3a8c917cd7b8f8906a8becddf6d33276507 --- /dev/null +++ b/packages/agent-core-v2/src/agent/contextMemory/types.ts @@ -0,0 +1,146 @@ +import type { Message } from '#/llm-adapter/contract/message'; +import type { ContentPart } from '#human/llm/message'; +import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; + +import type { AgentTaskStatus } from '#/agent/task/task'; + +export type SkillSource = 'project' | 'user' | 'extra' | 'builtin'; + +export interface PromptFileAttachment { + readonly name: string; + readonly mediaType: string; + readonly size: number; + readonly path: string; +} + +export interface UserPromptOrigin { + readonly kind: 'user'; + readonly clientMetadata?: readonly Readonly>[]; + readonly skillActivations?: readonly BundledSkillActivation[]; + readonly attachments?: readonly PromptFileAttachment[]; +} + +export const USER_PROMPT_ORIGIN: UserPromptOrigin = { kind: 'user' }; + +export interface BundledSkillActivation { + readonly activationId: string; + readonly skillName: string; + readonly skillArgs?: string; + readonly skillType?: string; + readonly skillPath?: string; + readonly skillSource?: SkillSource; +} + +export interface SkillActivationOrigin { + readonly kind: 'skill_activation'; + readonly clientMetadata?: readonly Readonly>[]; + readonly activationId: string; + readonly skillName: string; + readonly skillArgs?: string | undefined; + readonly trigger: 'user-slash' | 'model-tool' | 'nested-skill'; + readonly skillType?: string | undefined; + readonly skillPath?: string | undefined; + readonly skillSource?: SkillSource | undefined; + readonly attachments?: readonly PromptFileAttachment[]; +} + +export interface PluginCommandOrigin { + readonly kind: 'plugin_command'; + readonly activationId: string; + readonly pluginId: string; + readonly commandName: string; + readonly commandArgs?: string | undefined; + readonly trigger: 'user-slash'; +} + +export interface InjectionOrigin { + readonly kind: 'injection'; + readonly variant: string; + readonly ownerPromptId?: string; + readonly disclosure?: unknown; +} + +export interface ShellCommandOrigin { + readonly kind: 'shell_command'; + readonly phase: 'input' | 'output'; + readonly isError?: boolean; +} + +export interface CompactionSummaryOrigin { + readonly kind: 'compaction_summary'; +} + +export interface SystemTriggerOrigin { + readonly kind: 'system_trigger'; + readonly name: string; +} + +export interface TaskOrigin { + readonly kind: 'task'; + readonly taskId: string; + readonly status: AgentTaskStatus; + readonly notificationId: string; +} + +export interface CronJobOrigin { + readonly kind: 'cron_job'; + readonly jobId: string; + readonly cron: string; + readonly recurring: boolean; + readonly coalescedCount: number; + readonly stale: boolean; +} + +export interface CronMissedOrigin { + readonly kind: 'cron_missed'; + readonly count: number; +} + +export interface HookResultOrigin { + readonly kind: 'hook_result'; + readonly event: string; + readonly blocked?: boolean; +} + +export interface RetryOrigin { + readonly kind: 'retry'; + readonly trigger?: string; +} + +export type PromptOrigin = + | UserPromptOrigin + | SkillActivationOrigin + | PluginCommandOrigin + | InjectionOrigin + | ShellCommandOrigin + | CompactionSummaryOrigin + | SystemTriggerOrigin + | TaskOrigin + | CronJobOrigin + | CronMissedOrigin + | HookResultOrigin + | RetryOrigin; + +export type ContextMessage = Message & { + readonly id?: string; + readonly providerMessageId?: string; + readonly origin?: PromptOrigin | undefined; + readonly isError?: boolean; + toolCallDisplays?: Record; + readonly note?: string; +}; + +export interface UserMessageRecord { + content: readonly ContentPart[]; + origin: PromptOrigin; +} + +export interface SystemReminderRecord { + content: string; + origin: PromptOrigin; +} + +export interface AgentContextData { + history: readonly ContextMessage[]; + tokenCount: number; +} diff --git a/packages/agent-core-v2/src/agent/contextMemory/vacuousContent.ts b/packages/agent-core-v2/src/agent/contextMemory/vacuousContent.ts new file mode 100644 index 0000000000000000000000000000000000000000..d0bcf4729c1644b0a93ac42aa4e7be075e6c13fe --- /dev/null +++ b/packages/agent-core-v2/src/agent/contextMemory/vacuousContent.ts @@ -0,0 +1,19 @@ +import type { ContentPart } from '#human/llm/message'; + +export function isVacuousContentPart(part: ContentPart): boolean { + switch (part.type) { + case 'text': + return part.text.trim().length === 0; + case 'think': + return part.encrypted === undefined && part.think.trim().length === 0; + case 'image_url': + case 'audio_url': + case 'video_url': + return false; + default: { + const exhaustive: never = part; + void exhaustive; + return false; + } + } +} diff --git a/packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts b/packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts new file mode 100644 index 0000000000000000000000000000000000000000..60a230e4750287d5e1949c3af32ea502c8a56729 --- /dev/null +++ b/packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts @@ -0,0 +1,30 @@ +import { createDecorator } from '#/_base/di/instantiation'; +import type { Message } from '#/llm-adapter/contract/message'; + +import type { ContextMessage } from '#/agent/contextMemory/types'; + +declare const mediaStripSnapshotBrand: unique symbol; + +export interface MediaStripSnapshot { + readonly [mediaStripSnapshotBrand]: undefined; +} + +export interface ProjectionPolicy { + readonly structure?: 'strict'; + readonly media?: 'degraded' | { readonly strip: MediaStripSnapshot }; +} + +export interface IAgentContextProjectorService { + readonly _serviceBrand: undefined; + + project( + messages: readonly ContextMessage[], + policy?: ProjectionPolicy, + mediaPaths?: ReadonlyMap, + ): readonly Message[]; + captureMediaStripSnapshot(messages: readonly ContextMessage[]): MediaStripSnapshot; +} + +export const IAgentContextProjectorService = createDecorator( + 'agentContextProjectorService', +); diff --git a/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts new file mode 100644 index 0000000000000000000000000000000000000000..a76068f53e585c7fd5dac545d4157bdb60768907 --- /dev/null +++ b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts @@ -0,0 +1,145 @@ +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { defineState } from '#/state/state'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { IAgentStateService } from '#/agent/state/agentState'; +import type { Message } from '#/llm-adapter/contract/message'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { + IAgentContextProjectorService, + type MediaStripSnapshot, + type ProjectionPolicy, +} from './contextProjector'; +import { + MEDIA_DEGRADE_KEEP_RECENT, + captureMediaStripSnapshot, + degradeOlderMediaParts, + stripMediaPartsBySnapshot, +} from './mediaProjection'; +import { + project, + projectStrict, + summarizeProjectionRepairs, + type OnAnomaly, + type ProjectionAnomaly, +} from './projection'; + +export const contextProjectorLastRepairSignatureKey = defineState( + 'contextProjector.lastRepairSignature', + () => null, +); + +export class AgentContextProjectorService implements IAgentContextProjectorService { + declare readonly _serviceBrand: undefined; + + constructor( + @ILogService private readonly log: ILogService, + @ITelemetryService private readonly telemetry: ITelemetryService, + @IAgentStateService private readonly states: IAgentStateService, + ) { + this.states.contributeState(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[], + policy: ProjectionPolicy = {}, + mediaPaths?: ReadonlyMap, + ): readonly Message[] { + const projected = this.projectWithTrace( + messages, + policy.structure === 'strict' ? projectStrict : project, + ); + const media = policy.media; + if (media === undefined) return projected; + if (media === 'degraded') + return degradeOlderMediaParts(projected, MEDIA_DEGRADE_KEEP_RECENT, undefined, mediaPaths); + return stripMediaPartsBySnapshot(projected, media.strip, mediaPaths); + } + + captureMediaStripSnapshot(messages: readonly ContextMessage[]): MediaStripSnapshot { + return captureMediaStripSnapshot(this.projectWithTrace(messages, project)); + } + + private projectWithTrace( + messages: readonly ContextMessage[], + fn: (history: readonly ContextMessage[], onAnomaly?: OnAnomaly) => Message[], + ): readonly Message[] { + const anomalies: ProjectionAnomaly[] = []; + const result = fn(messages, (anomaly) => anomalies.push(anomaly)); + this.reportProjectionRepairs(anomalies); + return result; + } + + private reportProjectionRepairs(anomalies: readonly ProjectionAnomaly[]): void { + const notable = anomalies.filter( + (anomaly) => !(anomaly.kind === 'tool_result_synthesized' && anomaly.trailing), + ); + if (notable.length === 0) { + this.lastRepairSignature = null; + return; + } + const signature = notable + .map((anomaly) => ('toolCallId' in anomaly ? `${anomaly.kind}:${anomaly.toolCallId}` : anomaly.kind)) + .toSorted() + .join('|'); + if (signature === this.lastRepairSignature) return; + this.lastRepairSignature = signature; + + const { + reordered, + synthesized, + droppedOrphan, + duplicateCallsDropped, + duplicateResultsDropped, + leadingDropped, + assistantsMerged, + whitespaceDropped, + vacuousDropped, + } = summarizeProjectionRepairs(notable); + const toolCallIds = [ + ...new Set( + notable.flatMap((anomaly) => ('toolCallId' in anomaly ? [anomaly.toolCallId] : [])), + ), + ].slice(0, 5); + this.log.warn('repaired the request to keep it wire-valid', { + reordered, + synthesized, + droppedOrphan, + duplicateCallsDropped, + duplicateResultsDropped, + leadingDropped, + assistantsMerged, + whitespaceDropped, + vacuousDropped, + toolCallIds, + }); + this.telemetry.track2('context_projection_repaired', { + reordered, + synthesized, + dropped_orphan: droppedOrphan, + duplicate_calls_dropped: duplicateCallsDropped, + duplicate_results_dropped: duplicateResultsDropped, + leading_dropped: leadingDropped, + assistants_merged: assistantsMerged, + whitespace_dropped: whitespaceDropped, + vacuous_dropped: vacuousDropped, + }); + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentContextProjectorService, + AgentContextProjectorService, + ScopeActivation.OnScopeCreated, + 'contextProjector', +); diff --git a/packages/agent-core-v2/src/agent/contextProjector/mediaProjection.ts b/packages/agent-core-v2/src/agent/contextProjector/mediaProjection.ts new file mode 100644 index 0000000000000000000000000000000000000000..e1634d3f5ad14a083c8973d7987a83d2749a0292 --- /dev/null +++ b/packages/agent-core-v2/src/agent/contextProjector/mediaProjection.ts @@ -0,0 +1,152 @@ +import { createHash } from 'node:crypto'; + +import type { Message } from '#/llm-adapter/contract/message'; +import type { ContentPart } from '#human/llm/message'; + +import { buildMediaPathTag, mediaKindOfPart } from '#/agent/media/mediaRef'; + +import type { MediaStripSnapshot } from './contextProjector'; + +export const MEDIA_DEGRADE_KEEP_RECENT = 2; + +const MEDIA_DEGRADED_PLACEHOLDERS = { + image_url: + '[image omitted: dropped to fit the provider request size limit; re-read the file to view it]', + audio_url: + '[audio omitted: dropped to fit the provider request size limit; re-read the file to hear it]', + video_url: + '[video omitted: dropped to fit the provider request size limit; re-read the file to view it]', +} as const; + +export const MEDIA_STRIPPED_PLACEHOLDERS = { + image_url: + '[image omitted for provider compatibility; re-read the file to view it or get conversion guidance]', + audio_url: + '[audio omitted for provider compatibility; re-read the file to hear it]', + video_url: + '[video omitted for provider compatibility; re-read the file to view it]', +} as const; + +type MediaPlaceholderSet = typeof MEDIA_DEGRADED_PLACEHOLDERS | typeof MEDIA_STRIPPED_PLACEHOLDERS; + +type DegradableMediaPart = Extract< + ContentPart, + { readonly type: keyof MediaPlaceholderSet } +>; + +interface MediaContainer { + readonly url: string; + readonly id?: string; +} + +interface MediaStripSnapshotData { + readonly keys: ReadonlySet; +} + +type MediaContainerKeyCache = Partial>; + +const MEDIA_CONTAINER_KEY_CACHE = new WeakMap(); + +function isDegradableMediaPart( + part: ContentPart, +): part is DegradableMediaPart { + return part.type in MEDIA_DEGRADED_PLACEHOLDERS; +} + +function mediaContainer(part: DegradableMediaPart): MediaContainer { + if (part.type === 'image_url') return part.imageUrl; + if (part.type === 'audio_url') return part.audioUrl; + return part.videoUrl; +} + +function mediaStripKey(part: DegradableMediaPart): string { + const container = mediaContainer(part); + let cache = MEDIA_CONTAINER_KEY_CACHE.get(container); + const cached = cache?.[part.type]; + if (cached !== undefined) return cached; + + const key = createHash('sha256') + .update(part.type) + .update('\0') + .update(container.id ?? '') + .update('\0') + .update(container.url) + .digest('hex'); + if (cache === undefined) { + cache = {}; + MEDIA_CONTAINER_KEY_CACHE.set(container, cache); + } + cache[part.type] = key; + return key; +} + +function mediaStripSnapshotKeys(snapshot: MediaStripSnapshot): ReadonlySet { + return (snapshot as unknown as MediaStripSnapshotData).keys; +} + +function mediaPathTag( + part: DegradableMediaPart, + mediaPaths: ReadonlyMap | undefined, +): ContentPart | undefined { + const path = mediaPaths?.get(mediaContainer(part).url); + if (path === undefined) return undefined; + const kind = mediaKindOfPart(part); + if (kind === undefined) return undefined; + return { type: 'text', text: buildMediaPathTag(kind, path) }; +} + +export function captureMediaStripSnapshot( + messages: readonly Message[], +): MediaStripSnapshot { + const keys = new Set(); + for (const message of messages) { + for (const part of message.content) { + if (isDegradableMediaPart(part)) keys.add(mediaStripKey(part)); + } + } + return Object.freeze({ keys }) as unknown as MediaStripSnapshot; +} + +export function stripMediaPartsBySnapshot( + messages: readonly Message[], + snapshot: MediaStripSnapshot, + mediaPaths?: ReadonlyMap, +): readonly Message[] { + const keys = mediaStripSnapshotKeys(snapshot); + let changed = false; + const result = messages.map((message) => { + let messageChanged = false; + const content = message.content.map((part): ContentPart => { + if (!isDegradableMediaPart(part) || !keys.has(mediaStripKey(part))) return part; + changed = true; + messageChanged = true; + return mediaPathTag(part, mediaPaths) ?? { type: 'text', text: MEDIA_STRIPPED_PLACEHOLDERS[part.type] }; + }); + return messageChanged ? { ...message, content } : message; + }); + return changed ? result : messages; +} + +export function degradeOlderMediaParts( + messages: readonly Message[], + keepRecent: number, + placeholders: MediaPlaceholderSet = MEDIA_DEGRADED_PLACEHOLDERS, + mediaPaths?: ReadonlyMap, +): readonly Message[] { + const mediaCount = messages.reduce( + (count, message) => count + message.content.filter(isDegradableMediaPart).length, + 0, + ); + let toDegrade = Math.max(0, mediaCount - keepRecent); + if (toDegrade === 0) return messages; + + return messages.map((message) => { + if (toDegrade === 0 || !message.content.some(isDegradableMediaPart)) return message; + const content = message.content.map((part): ContentPart => { + if (toDegrade === 0 || !isDegradableMediaPart(part)) return part; + toDegrade -= 1; + return mediaPathTag(part, mediaPaths) ?? { type: 'text', text: placeholders[part.type] }; + }); + return { ...message, content }; + }); +} diff --git a/packages/agent-core-v2/src/agent/contextProjector/projection.ts b/packages/agent-core-v2/src/agent/contextProjector/projection.ts new file mode 100644 index 0000000000000000000000000000000000000000..8e837f82d986840f6680fd9365cb6ffd5f75c897 --- /dev/null +++ b/packages/agent-core-v2/src/agent/contextProjector/projection.ts @@ -0,0 +1,428 @@ +import { ErrorCodes, Error2 } from '#/errors'; +import { renderToolResultForModel } from '#/agent/contextMemory/toolResultRender'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent'; +import type { Message } from '#/llm-adapter/contract/message'; +import type { ContentPart } from '#human/llm/message'; + +export type ProjectionAnomaly = + | { readonly kind: 'tool_result_reordered'; readonly toolCallId: string } + | { readonly kind: 'tool_result_synthesized'; readonly toolCallId: string; readonly trailing: boolean } + | { readonly kind: 'orphan_tool_result_dropped'; readonly toolCallId: string } + | { readonly kind: 'duplicate_tool_call_dropped'; readonly toolCallId: string } + | { readonly kind: 'duplicate_tool_result_dropped'; readonly toolCallId: string } + | { readonly kind: 'leading_non_user_dropped'; readonly role: string } + | { readonly kind: 'consecutive_assistants_merged' } + | { readonly kind: 'whitespace_text_dropped'; readonly role: string } + | { readonly kind: 'vacuous_message_dropped'; readonly role: string }; + +export type OnAnomaly = (anomaly: ProjectionAnomaly) => void; + +export interface ProjectionRepairSummary { + readonly reordered: number; + readonly synthesized: number; + readonly droppedOrphan: number; + readonly duplicateCallsDropped: number; + readonly duplicateResultsDropped: number; + readonly leadingDropped: number; + readonly assistantsMerged: number; + readonly whitespaceDropped: number; + readonly vacuousDropped: number; +} + +export function summarizeProjectionRepairs( + anomalies: readonly ProjectionAnomaly[], +): ProjectionRepairSummary { + const summary = { + reordered: 0, + synthesized: 0, + droppedOrphan: 0, + duplicateCallsDropped: 0, + duplicateResultsDropped: 0, + leadingDropped: 0, + assistantsMerged: 0, + whitespaceDropped: 0, + vacuousDropped: 0, + }; + for (const anomaly of anomalies) { + if (anomaly.kind === 'tool_result_reordered') summary.reordered += 1; + else if (anomaly.kind === 'tool_result_synthesized') summary.synthesized += 1; + else if (anomaly.kind === 'orphan_tool_result_dropped') summary.droppedOrphan += 1; + else if (anomaly.kind === 'duplicate_tool_call_dropped') summary.duplicateCallsDropped += 1; + else if (anomaly.kind === 'duplicate_tool_result_dropped') summary.duplicateResultsDropped += 1; + else if (anomaly.kind === 'leading_non_user_dropped') summary.leadingDropped += 1; + else if (anomaly.kind === 'consecutive_assistants_merged') summary.assistantsMerged += 1; + else if (anomaly.kind === 'vacuous_message_dropped') summary.vacuousDropped += 1; + else summary.whitespaceDropped += 1; + } + return summary; +} + +export function project(history: readonly ContextMessage[], onAnomaly?: OnAnomaly): Message[] { + const layout = sliceLayout(history); + return flattenBlocks(pairBlocks(history, layout, onAnomaly), layout, onAnomaly); +} + +export function projectStrict( + history: readonly ContextMessage[], + onAnomaly?: OnAnomaly, +): Message[] { + const projected = project(history, onAnomaly); + return dropLeadingNonUserMessages( + mergeConsecutiveAssistantMessages(dedupeDuplicateToolCalls(projected, onAnomaly), onAnomaly), + onAnomaly, + ); +} + +interface SliceLayout { + readonly sizing: boolean; + readonly lastNonToolIndex: number; +} + +function sliceLayout(history: readonly ContextMessage[]): SliceLayout { + let sizing = true; + let lastNonToolIndex = -1; + for (const [index, message] of history.entries()) { + if (message.partial === true || message.role === 'tool') continue; + lastNonToolIndex = index; + if (message.role === 'assistant') sizing = false; + } + return { sizing, lastNonToolIndex }; +} + +interface AttachedResult { + readonly source: ContextMessage; + readonly content: ContentPart[]; +} + +const INTERRUPTED_RESULT = Symbol('interruptedResult'); + +interface PendingCall { + readonly callId: string; + result: AttachedResult | typeof INTERRUPTED_RESULT | undefined; + foreignBetween: boolean; +} + +interface Exchange { + readonly source: ContextMessage; + readonly content: ContentPart[]; + readonly ownerIndex: number; + readonly pending: PendingCall[]; +} + +type Block = + | { + readonly kind: 'message'; + readonly source: ContextMessage; + readonly content: ContentPart[]; + } + | { readonly kind: 'exchange'; readonly exchange: Exchange }; + +function pairBlocks( + history: readonly ContextMessage[], + layout: SliceLayout, + onAnomaly?: OnAnomaly, +): Block[] { + const blocks: Block[] = []; + const openCalls = new Map(); + + const markForeignBetween = (): void => { + for (const { pending } of openCalls.values()) pending.foreignBetween = true; + }; + + for (const [index, message] of history.entries()) { + if (message.partial === true) continue; + if (message.role === 'tool' && !layout.sizing) { + if (message.toolCallId === undefined) continue; + const open = openCalls.get(message.toolCallId); + if (open === undefined) { + markForeignBetween(); + onAnomaly?.({ kind: 'orphan_tool_result_dropped', toolCallId: message.toolCallId }); + continue; + } + openCalls.delete(message.toolCallId); + open.pending.result = { source: message, content: projectedContent(message, onAnomaly) }; + if (open.pending.foreignBetween) { + onAnomaly?.({ kind: 'tool_result_reordered', toolCallId: message.toolCallId }); + } + continue; + } + + const content = projectedContent(message, onAnomaly); + if (message.toolCalls.length === 0 && !hasDeclaredTools(message)) { + if (content.length === 0) continue; + if (content.every(isVacuousContentPart)) { + onAnomaly?.({ kind: 'vacuous_message_dropped', role: message.role }); + continue; + } + } + markForeignBetween(); + if (message.toolCalls.length === 0) { + blocks.push({ kind: 'message', source: message, content }); + continue; + } + + const exchange: Exchange = { source: message, content, ownerIndex: index, pending: [] }; + blocks.push({ kind: 'exchange', exchange }); + for (const call of message.toolCalls) { + const superseded = openCalls.get(call.id); + if (superseded !== undefined) { + superseded.pending.result = INTERRUPTED_RESULT; + onAnomaly?.({ + kind: 'tool_result_synthesized', + toolCallId: call.id, + trailing: superseded.exchange.ownerIndex >= layout.lastNonToolIndex, + }); + } + const pending: PendingCall = { callId: call.id, result: undefined, foreignBetween: false }; + exchange.pending.push(pending); + openCalls.set(call.id, { exchange, pending }); + } + } + return blocks; +} + +interface MergeState { + single: { readonly source: ContextMessage; readonly content: ContentPart[] } | undefined; + readonly texts: string[]; + readonly parts: ContentPart[]; +} + +function flattenBlocks( + blocks: readonly Block[], + layout: SliceLayout, + onAnomaly?: OnAnomaly, +): Message[] { + const out: Message[] = []; + let merge: MergeState | undefined; + + const flushMerge = (): void => { + if (merge === undefined) return; + if (merge.single !== undefined) { + out.push(toWireMessage(merge.single.source, merge.single.content)); + } else { + const text = merge.texts.join('\n\n'); + const content: ContentPart[] = text === '' ? [] : [{ type: 'text', text }]; + content.push(...merge.parts); + out.push({ + role: 'user', + name: undefined, + content, + toolCalls: [], + toolCallId: undefined, + partial: undefined, + }); + } + merge = undefined; + }; + + for (const block of blocks) { + if (block.kind === 'message') { + if (canMergeUserMessage(block.source)) { + if (merge === undefined) { + merge = { single: block, texts: [], parts: [] }; + } else { + if (merge.single !== undefined) { + appendMergeContent(merge, merge.single.content); + merge.single = undefined; + } + appendMergeContent(merge, block.content); + } + continue; + } + flushMerge(); + out.push(toWireMessage(block.source, block.content)); + continue; + } + + flushMerge(); + const { exchange } = block; + out.push(toWireMessage(exchange.source, exchange.content)); + for (const pending of exchange.pending) { + if (pending.result === undefined) { + out.push(createInterruptedToolResult(pending.callId)); + onAnomaly?.({ + kind: 'tool_result_synthesized', + toolCallId: pending.callId, + trailing: exchange.ownerIndex >= layout.lastNonToolIndex, + }); + } else if (pending.result === INTERRUPTED_RESULT) { + out.push(createInterruptedToolResult(pending.callId)); + } else { + out.push(toWireMessage(pending.result.source, pending.result.content)); + } + } + } + flushMerge(); + return out; +} + +function dedupeDuplicateToolCalls(messages: readonly Message[], onAnomaly?: OnAnomaly): Message[] { + const seenToolCallIds = new Set(); + const keptToolResultIndexes = new Map(); + const out: Message[] = []; + for (const message of messages) { + if (message.role === 'assistant' && message.toolCalls.length > 0) { + const kept = message.toolCalls.filter((toolCall) => { + if (seenToolCallIds.has(toolCall.id)) { + onAnomaly?.({ kind: 'duplicate_tool_call_dropped', toolCallId: toolCall.id }); + return false; + } + seenToolCallIds.add(toolCall.id); + return true; + }); + if (kept.length === message.toolCalls.length) { + out.push(message); + } else if (kept.length > 0 || !message.content.every(isVacuousContentPart)) { + out.push({ ...message, toolCalls: kept }); + } else if (message.content.length > 0) { + onAnomaly?.({ kind: 'vacuous_message_dropped', role: message.role }); + } + continue; + } + if (message.role === 'tool' && message.toolCallId !== undefined) { + const previousIndex = keptToolResultIndexes.get(message.toolCallId); + if (previousIndex !== undefined) { + if (isInterruptedToolResult(out[previousIndex]) && !isInterruptedToolResult(message)) { + out[previousIndex] = message; + } else { + onAnomaly?.({ kind: 'duplicate_tool_result_dropped', toolCallId: message.toolCallId }); + } + continue; + } + keptToolResultIndexes.set(message.toolCallId, out.length); + } + out.push(message); + } + return out; +} + +function mergeConsecutiveAssistantMessages( + messages: readonly Message[], + onAnomaly?: OnAnomaly, +): Message[] { + const out: Message[] = []; + for (const message of messages) { + const previous = out.at(-1); + if (previous !== undefined && previous.role === 'assistant' && message.role === 'assistant') { + out[out.length - 1] = { + ...previous, + content: [...previous.content, ...message.content], + toolCalls: [...previous.toolCalls, ...message.toolCalls], + }; + onAnomaly?.({ kind: 'consecutive_assistants_merged' }); + continue; + } + out.push(message); + } + return out; +} + +function dropLeadingNonUserMessages(messages: readonly Message[], onAnomaly?: OnAnomaly): Message[] { + let start = 0; + while (start < messages.length && messages[start]?.role !== 'user') { + onAnomaly?.({ kind: 'leading_non_user_dropped', role: messages[start]!.role }); + start += 1; + } + return start === 0 ? [...messages] : messages.slice(start); +} + +function appendMergeContent(group: MergeState, content: readonly ContentPart[]): void { + let text = ''; + for (const part of content) { + if (part.type === 'text') text += part.text; + else group.parts.push(part); + } + if (text.length > 0) group.texts.push(text); +} + +function projectedContent(source: ContextMessage, onAnomaly?: OnAnomaly): ContentPart[] { + const content = + source.role === 'tool' + ? renderToolResultForModel({ + output: outputFromToolContent(source.content), + isError: source.isError, + note: source.note, + }) + : source.content; + return cleanContent(source, content, onAnomaly); +} + +function cleanContent( + source: ContextMessage, + rawContent: readonly ContentPart[], + onAnomaly?: OnAnomaly, +): ContentPart[] { + const hasBlank = rawContent.some(isBlankText); + let content: readonly ContentPart[] = rawContent; + if (hasBlank) { + const filtered: ContentPart[] = []; + for (const part of rawContent) { + if (isBlankText(part)) { + if (part.type === 'text' && part.text.length > 0) { + onAnomaly?.({ kind: 'whitespace_text_dropped', role: source.role }); + } + } else { + filtered.push(part); + } + } + content = filtered; + } + if (source.role === 'tool' && content.length === 0) { + throw new Error2( + ErrorCodes.REQUEST_INVALID, + 'Tool result message content cannot be empty after removing empty text blocks.', + { details: { toolCallId: source.toolCallId } }, + ); + } + return [...content]; +} + +function outputFromToolContent(content: readonly ContentPart[]): string | readonly ContentPart[] { + const only = content[0]; + return content.length === 1 && only?.type === 'text' ? only.text : content; +} + +const TOOL_INTERRUPTED_TEXT = + 'Tool result is not available in the current context. Do not assume the tool completed successfully.'; + +function createInterruptedToolResult(toolCallId: string): Message { + return { + role: 'tool', + name: undefined, + content: [{ type: 'text', text: TOOL_INTERRUPTED_TEXT }], + toolCalls: [], + toolCallId, + partial: undefined, + }; +} + +function isInterruptedToolResult(message: Message | undefined): boolean { + if (message?.role !== 'tool') return false; + const [part] = message.content; + return part?.type === 'text' && part.text === TOOL_INTERRUPTED_TEXT; +} + +function isBlankText(part: ContentPart): boolean { + return part.type === 'text' && part.text.trim().length === 0; +} + +function canMergeUserMessage(message: ContextMessage): boolean { + return message.role === 'user' && message.origin?.kind === 'user'; +} + +function hasDeclaredTools(message: ContextMessage): boolean { + return message.tools !== undefined && message.tools.length > 0; +} + +function toWireMessage(message: ContextMessage, content: ContentPart[]): Message { + return { + role: message.role, + name: message.name, + content, + toolCalls: message.toolCalls, + toolCallId: message.toolCallId, + partial: message.partial, + tools: message.tools, + }; +} diff --git a/packages/agent-core-v2/src/agent/fullCompaction/compaction-instruction.md b/packages/agent-core-v2/src/agent/fullCompaction/compaction-instruction.md new file mode 100644 index 0000000000000000000000000000000000000000..90742b820bcb0b5ccdb3ee140733e0014e8d38e1 --- /dev/null +++ b/packages/agent-core-v2/src/agent/fullCompaction/compaction-instruction.md @@ -0,0 +1,73 @@ +You are about to run out of context. Create a handoff summary for the +model that will resume this task after the earlier conversation is cleared. + +--- This message is a direct task, not part of the above conversation --- + +Do not impose rigid section headings; let the shape follow the task. Write it +in the same language the conversation has been using — do not switch to English +just because these instructions happen to be in English. + +Make the summary self-sufficient: the next turn will see only the preserved +messages and this summary — every other assistant message, tool call, and tool +result above will be gone. In your own words, preserve what you genuinely need +to continue: + +- What the latest request is actually asking for: your reading of its intent and + any ambiguity you have already resolved — not a re-transcription, since what + fits is kept verbatim in the preserved messages. But those kept messages are + size-capped, so a long request is truncated there: if the latest request is + large (a big paste or file), preserve the parts at risk of being dropped — + above all the actual ask. If several requests are in play, say which one governs + the next move, and re-quote any still-relevant earlier request that may have + scrolled out of the kept messages. +- The instructions and constraints currently in force (user preferences, + project rules, environment and tooling limits) — condensed to what still + matters, keeping decisions you have already settled (what you chose and why) + separate from questions still open, so you neither silently reopen a closed + choice nor treat an undecided point as decided. +- What has actually been done, at high fidelity: keep the exact commands that + were run, the exact file paths touched, and whether each succeeded or failed — + and the results themselves, not just the commands: the concrete values + returned, the key lines or error text, the schema or signature a lookup + revealed, since re-running to recover them may be slow or impossible. Keep only + the final working version of any code; drop intermediate attempts and + already-resolved errors. +- What you still don't know: context the next step depends on that this + conversation never established — files or paths referenced but not yet read, + schemas or APIs assumed but unseen, questions the user has not answered. Name + these gaps so the next turn goes and checks them instead of assuming. +- The forward plan — and this is the moment to invest in it. Right now you + hold more context on this task than you ever will again; the next turn + resumes with less, so the plan you commit here is the one it will follow. + Give the exact next command or tool call, but don't stop at the next step: + set out the remaining sequence to finish, the decisions you have already + made for those upcoming steps (so the next turn doesn't reopen them), the + obstacles or edge cases you can already foresee and how you mean to handle + them, and any work you can commit to now — the exact patch, query, or shape + of the final answer you already know you will produce. Anything you settle + here is one less thing the next turn must rediscover. Include any required + format for the final answer. + +This conversation's event log stays on disk and a recovery pointer is appended below this summary automatically, so you need not reproduce long outputs verbatim — keep exact identifiers, key values and error lines, and name anything the next turn should look up. + +Your TODO list is re-attached automatically below this summary from its live +source, so do not transcribe it — copying it wastes space and can contradict the +live version. What that list cannot hold is the reasoning between tasks — why one +was reordered or dropped, or a decision on one that constrains another — so +record that instead. + +Be honest about uncertainty. If an earlier step claimed something was done but +was never verified (tests "passing", a fix "working", a file "created"), say so +plainly and treat it as unverified rather than fact — re-check before relying +on it. + +Be concise, and keep the summary proportional to the task: a long multi-step +task warrants detail, but a trivial or nearly finished exchange needs only a +sentence or two — do not pad it out. Include the critical data, identifiers, and +references needed to continue, and omit anything that does not change the next +move. + +Respond with text only. Do not call any tools — you already have everything you +need in the conversation history. + +${custom_instruction_block} diff --git a/packages/agent-core-v2/src/agent/fullCompaction/compactionInstruction.ts b/packages/agent-core-v2/src/agent/fullCompaction/compactionInstruction.ts new file mode 100644 index 0000000000000000000000000000000000000000..cd3a10f916fe9171a9439c37eea835ba623f9252 --- /dev/null +++ b/packages/agent-core-v2/src/agent/fullCompaction/compactionInstruction.ts @@ -0,0 +1,15 @@ +import { renderPrompt } from '#/_base/utils/render-prompt'; + +import compactionInstructionTemplate from './compaction-instruction.md?raw'; + +export interface CompactionInstructionInput { + readonly customInstruction?: string; +} + +export function renderCompactionInstruction(input: CompactionInstructionInput): string { + const customInstruction = input.customInstruction?.trim() ?? ''; + return renderPrompt(compactionInstructionTemplate, { + custom_instruction_block: + customInstruction.length > 0 ? `\nOptional user instruction:\n${customInstruction}\n` : '', + }).trimEnd(); +} diff --git a/packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts b/packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts new file mode 100644 index 0000000000000000000000000000000000000000..ddd55217e912cef93b57816b510ce90890036a30 --- /dev/null +++ b/packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts @@ -0,0 +1,158 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { z } from 'zod'; + +import { AgentEvent2, type AgentDomainTrait } from '#/app/event/event2'; +import { defineState } from '#/state/state'; +import { + ContextApplyCompaction, + ContextClear, + type ContextApplyCompactionPayload, +} from '#/agent/contextMemory/contextEvents'; +import type { WireLineRange } from '#/wire/record'; + +import type { CompactionBeginData, CompactionResult, CompactionSource } from './types'; + +export type CompactionPhase = 'idle' | 'running' | 'cancelled' | 'completed'; + +export interface CompactionState { + readonly phase: CompactionPhase; +} + +const fullCompactionBeginSchema = z.object({ + agentId: z.string(), + instruction: z.string().optional(), + source: z.custom(), +}); + +export class FullCompactionBegin extends AgentEvent2< + z.infer +> { + static override readonly type = 'full_compaction.begin'; + static override readonly durable = true; + static override readonly schema = fullCompactionBeginSchema; +} +export interface FullCompactionBegin extends CompactionBeginData { + readonly agentId: string; +} + +const fullCompactionCancelSchema = z.object({ agentId: z.string() }); + +export class FullCompactionCancel extends AgentEvent2< + z.infer +> { + static override readonly type = 'full_compaction.cancel'; + static override readonly durable = true; + static override readonly schema = fullCompactionCancelSchema; +} +export interface FullCompactionCancel { + readonly agentId: string; +} + +const fullCompactionCompleteSchema = z.object({ agentId: z.string() }); + +export class FullCompactionComplete extends AgentEvent2< + z.infer +> { + static override readonly type = 'full_compaction.complete'; + static override readonly durable = true; + static override readonly schema = fullCompactionCompleteSchema; +} +export interface FullCompactionComplete { + readonly agentId: string; +} + +export interface CompactionStartedPayload { + readonly agentId: string; + readonly trigger: CompactionSource; + readonly instruction?: string; +} + +export class CompactionStarted extends AgentEvent2 { + static override readonly type = 'compaction.started'; + static override readonly observable = true; +} +export interface CompactionStarted extends CompactionStartedPayload {} + +export interface CompactionBlockedPayload { + readonly agentId: string; + readonly turnId?: number; +} + +export class CompactionBlocked extends AgentEvent2 { + static override readonly type = 'compaction.blocked'; + static override readonly observable = true; +} +export interface CompactionBlocked extends CompactionBlockedPayload {} + +export class CompactionCancelled extends AgentEvent2 { + static override readonly type = 'compaction.cancelled'; + static override readonly observable = true; +} +export interface CompactionCancelled { + readonly agentId: string; +} + +export interface CompactionCompletedPayload { + readonly agentId: string; + readonly result: CompactionResult; +} + +export class CompactionCompleted extends AgentEvent2 { + static override readonly type = 'compaction.completed'; + static override readonly observable = true; +} +export interface CompactionCompleted extends CompactionCompletedPayload {} + +export interface CompactionStartedEvent extends Omit { + readonly type: 'compaction.started'; +} + +export interface CompactionBlockedEvent extends Omit { + readonly type: 'compaction.blocked'; +} + +export interface CompactionCancelledEvent { + readonly type: 'compaction.cancelled'; +} + +export interface CompactionCompletedEvent extends Omit { + readonly type: 'compaction.completed'; +} + +export const fullCompactionKey = defineState( + 'fullCompaction', + (): CompactionState => ({ phase: 'idle' }), +).replayable({ schema: z.custom() }) + .on(FullCompactionBegin, (s, e, ctx) => { + if (s.phase !== 'running') { + s.phase = 'running'; + } + ctx.emit( + new CompactionStarted({ + agentId: e.agentId, + trigger: e.source, + instruction: e.instruction, + }), + ); + }) + .on(FullCompactionCancel, (s) => { + if (s.phase !== 'idle') { + s.phase = 'idle'; + } + }) + .on(FullCompactionComplete, (s) => { + if (s.phase !== 'idle') { + s.phase = 'idle'; + } + }); + +export const fullCompactionWireRangesKey = defineState( + 'fullCompaction.wireRanges', + () => [], +) + .replayable({ schema: z.custom() }) + .on(ContextApplyCompaction, (s, e) => { + const wireLines = (e as unknown as ContextApplyCompactionPayload).wireLines; + return wireLines === undefined ? undefined : [...s, wireLines]; + }) + .on(ContextClear, (s) => (s.length === 0 ? undefined : [])); diff --git a/packages/agent-core-v2/src/agent/fullCompaction/context-recovery-footer.md b/packages/agent-core-v2/src/agent/fullCompaction/context-recovery-footer.md new file mode 100644 index 0000000000000000000000000000000000000000..f3ba114f79de22b86ff40ea503ae652b857cba24 --- /dev/null +++ b/packages/agent-core-v2/src/agent/fullCompaction/context-recovery-footer.md @@ -0,0 +1,10 @@ +## Context Recovery +Everything before this note is still on disk in this agent's event log (read-only, append-only): + ${wire_path} +${window_lines} +If you need exact command output, file contents, error text, or the wording of an earlier request, look it up there instead of guessing. How to read it: +- Layout: one file per agent. agents/main/ is the main agent; each subagent has its own agents//wire.jsonl. A parent's log holds only the Agent tool call and the subagent's returned result — the subagent's own steps are in its own file. +- Format: one JSON record per line, append-only; `type` says what it is. The conversation is in `context.append_message` (user prompts) and `context.append_loop_event` (event.type: step.begin | content.part [text|think] | tool.call | tool.result | step.end). Every other type (llm.request, usage.record, token_counting.measured, metadata, profile.bind, …) is bookkeeping — skip it. +- Boundaries: `context.apply_compaction` marks a compaction (older lines stay in the file; grep for it to find exact boundaries). `context.undo` count=N retracts the previous N messages — treat retracted content as never having happened. `context.clear` resets the conversation. +- Externalized content: tool results over 50k chars may contain an `output_path` pointing to saved output; check the result's preservation notice before assuming the file contains everything. Read results are bounded by their own character budget and are not spilled again. Media parts are blob references, not inline. +- Reading: lines are long JSON (often 10k+ chars). Grep the file for a keyword to get line numbers, then Read exactly that line (line_offset=N, n_lines=1). Long records can span several Read results: follow Next Read with its column_offset until the line is complete, joining fragments without inserting newlines. To pull one field with real newlines when Bash is available: sed -n 'Np' wire.jsonl | jq -r '.event.result.output'. Prefer individual records over large ranges. diff --git a/packages/agent-core-v2/src/agent/fullCompaction/contextRecovery.ts b/packages/agent-core-v2/src/agent/fullCompaction/contextRecovery.ts new file mode 100644 index 0000000000000000000000000000000000000000..430f821c46dddb23c8bde29e36c9972da4ac2b44 --- /dev/null +++ b/packages/agent-core-v2/src/agent/fullCompaction/contextRecovery.ts @@ -0,0 +1,28 @@ +import { renderPrompt } from '#/_base/utils/render-prompt'; +import type { WireLineRange } from '#/wire/record'; + +import contextRecoveryTemplate from './context-recovery-footer.md?raw'; + +export const CONTEXT_RECOVERY_HEADING = '## Context Recovery'; + +export interface ContextRecoveryPointer { + readonly journalPath: string; + readonly windows: readonly WireLineRange[]; +} + +export function renderContextRecoveryPointer(pointer: ContextRecoveryPointer): string { + const windows = pointer.windows; + const summarized = windows.length - 1; + const lines = windows.map((range, index) => { + const label = `window ${String(index + 1)}: lines ${String(range.start)}–${String(range.end)}`; + return index === summarized ? `${label} ← the conversation this note summarizes` : label; + }); + const nextStart = windows[summarized]!.end + 1; + lines.push( + `window ${String(windows.length + 1)} (the one you are in now) starts at line ${String(nextStart)} with the \`context.apply_compaction\` record that carries this note — it is already in your context; no need to read it.`, + ); + return renderPrompt(contextRecoveryTemplate, { + wire_path: pointer.journalPath, + window_lines: lines.map((line) => ` ${line}`).join('\n'), + }).trimEnd(); +} diff --git a/packages/agent-core-v2/src/agent/fullCompaction/errors.ts b/packages/agent-core-v2/src/agent/fullCompaction/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..554374ae33522b6c7bc32ac6edffc64a5a8a7bce --- /dev/null +++ b/packages/agent-core-v2/src/agent/fullCompaction/errors.ts @@ -0,0 +1,10 @@ +import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; + +export const FullCompactionErrors = { + codes: { + COMPACTION_FAILED: 'compaction.failed', + COMPACTION_UNABLE: 'compaction.unable', + }, +} as const satisfies ErrorDomain; + +registerErrorDomain(FullCompactionErrors); diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts new file mode 100644 index 0000000000000000000000000000000000000000..88c70d19440b3c50bd31cac303c82c6699808e58 --- /dev/null +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts @@ -0,0 +1,36 @@ +import type { + CompactionResult, + CompactionSource, +} from './types'; +import { createDecorator } from "#/_base/di/instantiation"; +import type { Event } from '#/_base/event'; +import type { Hooks } from '#/hooks'; + +export interface FullCompactionInput { + readonly source: CompactionSource; + readonly instruction?: string; +} + +export interface FullCompactionTask { + readonly abortController: AbortController; + readonly promise: Promise; + readonly trigger: CompactionSource; + readonly tokenCount: number; + readonly traceId?: string; +} + +export interface IAgentFullCompactionService { + readonly _serviceBrand: undefined; + + readonly compacting: FullCompactionTask | null; + begin(input: FullCompactionInput): boolean; + cancel(): void; + + readonly hooks: Hooks<{ + onWillCompact: FullCompactionTask; + }>; + + readonly onDidFinishCompaction: Event; +} + +export const IAgentFullCompactionService = createDecorator('agentFullCompactionService'); diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts new file mode 100644 index 0000000000000000000000000000000000000000..1c8f4f7f5c4a1fb24d93196c5e28cb6d9f27a478 --- /dev/null +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -0,0 +1,955 @@ +import type { IDisposable } from '#/_base/di/lifecycle'; +import { Service } from "#/_base/di/service"; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { defineState } from '#/state/state'; +import { estimateTokensForMessage } from "#/llm-adapter/contract/tokens"; +import { buildCompactionSummaryText, isRealUserInput } from '#/agent/contextMemory/compactionHandoff'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { ISessionTokenCountingService } from '#/session/tokenCounting/sessionTokenCounting'; +import { IAgentLLMRequesterService, type AgentLLMRequestFinish } from '#/agent/llmRequester/llmRequester'; +import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; +import { retryBackoffDelay, sleepForRetry } from '#/_base/utils/retry'; +import { runWithCredentialRecovery } from '#/llm-adapter/model/credential-recovery'; +import { IAgentLoopService, type LoopErrorContext } from '#/agent/loop/loop'; +import { TurnStarted } from '#/agent/loop/turnEvents'; +import { TurnEnded } from '#/agent/loop/turnOps'; +import { isAbortError } from '#/_base/utils/abort'; +import { IAgentProfileService, type ProfileModelContext } from '#/agent/profile/profile'; +import { + agentContextOfScope, + IAgentScopeContext, +} from '#/agent/scopeContext/scopeContext'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; +import { stripDynamicToolContext } from '#/agent/toolSelect/dynamicTools'; +import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect'; +import { IAgentTodoService } from '#/features/todo/todoService'; +import { renderTodoList } from '#/features/todo/todoItem'; +import { onUnexpectedError } from '#/_base/errors/unexpectedError'; +import type { WireLineRange } from '#/wire/record'; +import { IWireService } from '#/wire/wire'; +import { + APIContextOverflowError, + APIEmptyResponseError, + APIStatusError, + isRetryableGenerateError, +} from '#/llm-adapter/contract/errors'; +import { createUserMessage, type Message } from '#/llm-adapter/contract/message'; +import type { ToolDescription as Tool } from '#human/llm/message'; +import { inputTotal, type TokenUsage } from '#human/llm/usage'; +import { IEventBus } from '#/app/event/eventBus'; +import type { CompactionFailedEvent, CompactionFinishedEvent } from '#/app/telemetry/events'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { ErrorCodes, Error2, isCodedError, isError2, toKimiErrorPayload, unwrapErrorCause } from "#/errors"; +import { AgentErrorEvent } from '#/agent/mcp/mcpEvents'; +import { IEventDispatcher } from '#/state/eventDispatcher'; +import { renderCompactionInstruction } from './compactionInstruction'; +import { renderContextRecoveryPointer } from './contextRecovery'; +import { + IAgentFullCompactionService, + type FullCompactionInput, + type FullCompactionTask, +} from './fullCompaction'; +import { + RuntimeCompactionStrategy, + type CompactionStrategy, +} from './strategy'; +import { + CompactionBlocked, + CompactionCancelled, + CompactionCompleted, + fullCompactionKey, + fullCompactionWireRangesKey, + FullCompactionBegin, + FullCompactionCancel, + FullCompactionComplete, +} from './compactionOps'; +import { + type CompactionBeginData, + type CompactionResult, +} from './types'; +import { Emitter, type Event } from '#/_base/event'; +import { OrderedHookSlot } from '#/hooks'; + +export const MAX_COMPACTION_RETRY_ATTEMPTS = 5; +const DEFAULT_COMPACTION_MAX_COMPLETION_TOKENS = 128 * 1024; +const OVERFLOW_CONTEXT_SAFETY_RATIO = 0.85; +const OVERFLOW_STATUS_RECOVERY_RATIO = 0.5; +const MAX_COMPACTION_OVERFLOW_SHRINK_ATTEMPTS = 3; +const COMPACTION_OVERFLOW_SHRINK_RATIOS = [0.7, 0.5, 0.35] as const; +const EMPTY_TOOL_PARAMETERS: Record = { + type: 'object', + properties: {}, +}; + +type CompactionTelemetryProperties = Pick< + CompactionFinishedEvent, + 'input_tokens' | 'output_tokens' | 'input_cache_read' | 'input_cache_creation' +>; + +interface ActiveCompaction extends FullCompactionTask { + readonly originTurnId?: number; + readonly quiescence?: IDisposable; + trace?: LLMRequestTrace; + blockedByTurn: boolean; +} + +interface CompactionAttemptResult { + readonly summary: string; + readonly usage: TokenUsage | null; + readonly traceId?: string; +} + +class CompactionTruncatedError extends Error { + constructor() { + super('Compaction response was truncated before producing a complete summary.'); + this.name = 'CompactionTruncatedError'; + } +} + +export const fullCompactionCompactionCountInTurnKey = defineState( + 'fullCompaction.compactionCountInTurn', + () => 0, +); +export const fullCompactionObservedMaxContextTokensByModelKey = defineState>( + 'fullCompaction.observedMaxContextTokensByModel', + () => new Map(), +); +export const fullCompactionLastCompactedTokenCountKey = defineState( + 'fullCompaction.lastCompactedTokenCount', + () => null, +); +export const fullCompactionConsecutiveOverflowCompactionsKey = defineState( + 'fullCompaction.consecutiveOverflowCompactions', + () => 0, +); +export const fullCompactionActiveTurnIdKey = defineState( + 'fullCompaction.activeTurnId', + () => undefined as number | undefined, +); + +export class AgentFullCompactionService extends Service implements IAgentFullCompactionService { + declare readonly _serviceBrand: undefined; + readonly hooks: IAgentFullCompactionService['hooks'] = { + onWillCompact: new OrderedHookSlot(), + }; + private readonly _onDidFinishCompaction = this._register(new Emitter()); + readonly onDidFinishCompaction: Event = this._onDidFinishCompaction.event; + + private readonly strategy: CompactionStrategy; + private _compacting: ActiveCompaction | null = null; + + constructor( + @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + @ISessionTokenCountingService private readonly tokenCounting: ISessionTokenCountingService, + @IAgentLLMRequesterService private readonly llmRequester: IAgentLLMRequesterService, + @IAgentProfileService private readonly profile: IAgentProfileService, + @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, + @IAgentToolSelectService private readonly toolSelect: IAgentToolSelectService, + @IAgentScopeContext private readonly agent: IAgentScopeContext, + @IAgentTodoService private readonly todo: IAgentTodoService, + @ITelemetryService private readonly telemetry: ITelemetryService, + @IEventDispatcher private readonly dispatcher: IEventDispatcher, + @IEventBus private readonly eventBus: IEventBus, + @IAgentLoopService private readonly loopService: IAgentLoopService, + @IAgentStateService private readonly states: IAgentStateService, + @IWireService private readonly wire: IWireService, + ) { + super(); + this.states.contributeState(fullCompactionKey); + this.states.contributeState(fullCompactionWireRangesKey); + this.states.contributeState(fullCompactionCompactionCountInTurnKey); + this.states.contributeState(fullCompactionObservedMaxContextTokensByModelKey); + this.states.contributeState(fullCompactionLastCompactedTokenCountKey); + this.states.contributeState(fullCompactionConsecutiveOverflowCompactionsKey); + this.states.contributeState(fullCompactionActiveTurnIdKey); + this.strategy = new RuntimeCompactionStrategy( + () => this.resolveModelContextWithEffectiveMax(), + (message) => this.tokenCounting.estimateMessage(message), + ); + this._register( + this.dispatcher.hooks.onDidRestore.register('full-compaction', async (_ctx, next) => { + this.normalizeAfterReplay(); + await next(); + }), + ); + this._register( + this.eventBus.subscribe(TurnStarted, () => this.resetForTurn()), + ); + this._register( + this.eventBus.subscribe(TurnEnded, () => { + this.activeTurnId = undefined; + }), + ); + this._register( + this.loopService.hooks.onWillBeginStep.register('full-compaction', async (ctx, next) => { + await this.beforeStep(ctx.signal, ctx.turnId); + await next(); + }), + ); + this._register( + this.loopService.hooks.onDidFinishStep.register('full-compaction', async (_ctx, next) => { + await this.afterStep(); + await next(); + }), + ); + this._register( + this.loopService.registerLoopErrorHandler({ + id: 'full-compaction', + match: (context) => this.shouldRecoverFromContextOverflow(context.error), + handle: (context) => this.recoverFromContextOverflow(context), + }), + ); + } + + private get compactionCountInTurn(): number { + return this.states.get(fullCompactionCompactionCountInTurnKey); + } + + private set compactionCountInTurn(value: number) { + this.states.set(fullCompactionCompactionCountInTurnKey, value); + } + + private get observedMaxContextTokensByModel(): Map { + 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; + } + + cancel(): void { + const active = this._compacting; + if (active !== null) { + this.telemetry.track2('cancel', { + from: 'compacting', + trace_id: active.traceId, + }); + } + active?.abortController.abort(); + } + + private getEffectiveMaxContextTokens(): number { + const capability = this.profile.data().modelCapabilities; + const configured = capability.max_input_tokens ?? capability.max_context_tokens; + const modelAlias = this.profile.data().modelAlias; + const observed = + modelAlias === undefined ? undefined : this.observedMaxContextTokensByModel.get(modelAlias); + if (observed === undefined) return configured; + if (configured <= 0) return observed; + return Math.min(configured, observed); + } + + private resolveModelContextWithEffectiveMax(): ProfileModelContext { + const resolved = this.profile.resolveModelContext(); + const effectiveMax = this.getEffectiveMaxContextTokens(); + return { + ...resolved, + modelCapabilities: { + ...resolved.modelCapabilities, + max_context_tokens: effectiveMax, + max_input_tokens: effectiveMax, + }, + }; + } + + private currentRequestTokens(): number { + return this.requestTokens(this.context.get()); + } + + private requestTokens(messages: readonly Message[]): number { + return this.tokenCounting.requestSize({ + systemPrompt: this.profile.getSystemPrompt(), + tools: this.defaultTools().filter((tool) => tool.deferred !== true), + messages, + }); + } + + private defaultTools(): readonly Tool[] { + return this.toolSelect + .shapeTools(this.toolRegistry.list()) + .map((tool) => ({ + name: tool.name, + description: tool.description, + parameters: tool.parameters ?? EMPTY_TOOL_PARAMETERS, + deferred: tool.deferred, + })); + } + + private shouldRecoverFromContextOverflow( + error: unknown, + estimatedRequestTokens = this.currentRequestTokens(), + ): boolean { + if (isCodedError(error) && error.code === ErrorCodes.CONTEXT_OVERFLOW) return true; + const statusError = findAPIStatusError(error); + if (statusError instanceof APIContextOverflowError) return true; + if (statusError === undefined || statusError.statusCode !== 413) return false; + const effectiveMax = this.getEffectiveMaxContextTokens(); + return ( + effectiveMax > 0 && + estimatedRequestTokens >= effectiveMax * OVERFLOW_STATUS_RECOVERY_RATIO + ); + } + + private observeContextOverflow(estimatedRequestTokens: number): void { + if (!Number.isFinite(estimatedRequestTokens) || estimatedRequestTokens <= 0) return; + const modelAlias = this.profile.data().modelAlias; + if (modelAlias === undefined) return; + const observed = Math.max( + 1, + Math.floor(estimatedRequestTokens * OVERFLOW_CONTEXT_SAFETY_RATIO), + ); + const current = this.getEffectiveMaxContextTokens(); + if (current > 0 && observed >= current) return; + this.observedMaxContextTokensByModel.set(modelAlias, observed); + } + + begin(input: FullCompactionInput): boolean { + if (this._compacting) return false; + const data: CompactionBeginData = { source: input.source, instruction: input.instruction }; + if (!this.reserveCompactionSlot(data.source)) return false; + + const tokenCount = this.validateCompactionStart(data.source); + const quiescence = data.source === 'manual' + ? this.loopService.tryAcquireQuiescence() + : undefined; + if (data.source === 'manual' && quiescence === undefined) { + throw new Error2( + ErrorCodes.COMPACTION_UNABLE, + 'Cannot compact while a turn is active or another context change is running. Wait for it to finish, then retry.', + ); + } + try { + void this.dispatcher.dispatch( + new FullCompactionBegin({ ...data, agentId: this.agent.agentId }), + ); + + const active = this.createActiveCompaction( + data.source, + tokenCount, + data.source === 'auto' ? this.activeTurnId : undefined, + quiescence, + ); + this._compacting = active.task; + active.task.abortController.signal.addEventListener( + 'abort', + () => this.cancelActive(active.task), + { once: true }, + ); + void this.compactionWorker(active.task, data).then(active.resolve, active.reject); + void active.task.promise.catch(() => undefined); + return true; + } catch (error) { + quiescence?.dispose(); + throw error; + } + } + + private reserveCompactionSlot(source: CompactionBeginData['source']): boolean { + if (source === 'manual') { + this.compactionCountInTurn = 0; + } else { + this.compactionCountInTurn += 1; + } + return this.compactionCountInTurn <= this.strategy.maxCompactionPerTurn; + } + + private validateCompactionStart(source: CompactionBeginData['source']): number { + const history = this.context.get(); + if (history.length === 0) { + throw new Error2(ErrorCodes.COMPACTION_UNABLE, 'No messages to compact in current history.'); + } + if (source === 'manual' && this.loopService.snapshot().state !== 'idle') { + throw new Error2( + ErrorCodes.COMPACTION_UNABLE, + 'Cannot compact while a turn is active. Wait for it to finish, then retry.', + ); + } + return this.requestTokens(history); + } + + private createActiveCompaction( + trigger: CompactionBeginData['source'], + tokenCount: number, + originTurnId: number | undefined, + quiescence: IDisposable | undefined, + ): { + readonly task: ActiveCompaction; + readonly resolve: (result: CompactionResult) => void; + readonly reject: (reason: unknown) => void; + } { + const abortController = new AbortController(); + let resolve!: (result: CompactionResult) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((onResolve, onReject) => { + resolve = onResolve; + reject = onReject; + }); + return { + task: { + abortController, + promise, + trigger, + tokenCount, + originTurnId, + quiescence, + get traceId() { + return this.trace?.traceId; + }, + blockedByTurn: false, + }, + resolve, + reject, + }; + } + + override dispose(): void { + if (this._compacting !== null && !this._compacting.abortController.signal.aborted) { + this._compacting.abortController.abort(); + } + super.dispose(); + } + + private cancelActive(active: ActiveCompaction): boolean { + if (this._compacting !== active) return false; + void this.dispatcher.dispatch(new FullCompactionCancel({ agentId: this.agent.agentId })); + this._compacting = null; + if (!active.abortController.signal.aborted) { + active.abortController.abort(); + } + void this.dispatcher.dispatch(new CompactionCancelled({ agentId: this.agent.agentId })); + return true; + } + + private markCompleted(active: ActiveCompaction): boolean { + if (this._compacting !== active) return false; + void this.dispatcher.dispatch(new FullCompactionComplete({ agentId: this.agent.agentId })); + this._compacting = null; + return true; + } + + private normalizeAfterReplay(): void { + if (this.states.get(fullCompactionKey).phase !== 'running') return; + void this.dispatcher.dispatch(new FullCompactionCancel({ agentId: this.agent.agentId })); + } + + private resetForTurn(): void { + this.compactionCountInTurn = 0; + this.lastCompactedTokenCount = null; + this.consecutiveOverflowCompactions = 0; + } + + private async recoverFromContextOverflow( + context: LoopErrorContext, + ): Promise { + this.recordOverflowRecovery(context.error); + const didStartCompaction = this.beginAutoCompaction(); + if (!didStartCompaction && !this._compacting) return false; + + await this.block(context.signal, context.turnId); + return this.retryFailedDriver(context); + } + + private recordOverflowRecovery(error: unknown): void { + this.observeContextOverflow(this.currentRequestTokens()); + this.consecutiveOverflowCompactions += 1; + const maxAttempts = this.strategy.maxOverflowCompactionAttempts; + if (this.consecutiveOverflowCompactions <= maxAttempts) return; + throw new Error2( + ErrorCodes.CONTEXT_OVERFLOW, + `Compaction failed to bring the context under the model window after ${String(maxAttempts)} attempts.`, + { cause: error instanceof Error ? error : undefined }, + ); + } + + private retryFailedDriver(context: LoopErrorContext): boolean { + if (context.signal.aborted) return false; + context.retry(); + return true; + } + + private async beforeStep(signal: AbortSignal, turnId?: number): Promise { + this.activeTurnId = turnId; + this.checkAutoCompaction(); + if (this.strategy.shouldBlock(this.tokenCountWithPending())) { + await this.block(signal, turnId); + } + } + + private async afterStep(): Promise { + this.consecutiveOverflowCompactions = 0; + if (this.strategy.checkAfterStep) { + this.checkAutoCompaction(false); + } + } + + private checkAutoCompaction(throwOnLimit = true): boolean { + if (this._compacting) return true; + if ( + this.lastCompactedTokenCount !== null && + this.tokenCountWithPending() <= this.lastCompactedTokenCount + ) { + return false; + } + if (!this.strategy.shouldCompact(this.tokenCountWithPending())) return false; + return this.beginAutoCompaction(throwOnLimit); + } + + private beginAutoCompaction(throwOnLimit = true): boolean { + if (this._compacting) return true; + const maxCompactions = this.strategy.maxCompactionPerTurn; + if (this.compactionCountInTurn >= maxCompactions) { + if (throwOnLimit) { + throw new Error2(ErrorCodes.CONTEXT_OVERFLOW, `Compaction limit exceeded (${String(maxCompactions)})`, { + details: { maxCompactions }, + }); + } + return false; + } + return this.begin({ source: 'auto' }); + } + + private async block(signal?: AbortSignal, turnId?: number): Promise { + const active = this._compacting; + if (active === null) return; + active.blockedByTurn = true; + this.propagateBlockingAbort(active, signal); + void this.dispatcher.dispatch( + new CompactionBlocked({ agentId: this.agent.agentId, turnId }), + ); + try { + await active.promise; + } catch (error) { + if (this.wasBlockingWaitAborted(active, signal, error)) return; + throw error; + } + } + + private propagateBlockingAbort(active: ActiveCompaction, signal: AbortSignal | undefined): void { + signal?.addEventListener( + 'abort', + () => { + if (this._compacting === active) active.abortController.abort(); + }, + { once: true }, + ); + } + + private wasBlockingWaitAborted( + active: ActiveCompaction, + signal: AbortSignal | undefined, + error: unknown, + ): boolean { + return ( + signal?.aborted === true && + (active.abortController.signal.aborted || isAbortError(error)) + ); + } + + private async compactionWorker( + active: ActiveCompaction, + data: Readonly, + ): Promise { + try { + const result = await this.compactionRound(active, data); + if (this._compacting !== active) throw compactionCancelledReason(active); + this.lastCompactedTokenCount = result.tokensAfter; + if (!this.markCompleted(active)) { + throw compactionCancelledReason(active); + } + const { contextSummary: _contextSummary, ...eventResult } = result; + void _contextSummary; + void this.dispatcher.dispatch( + new CompactionCompleted({ agentId: this.agent.agentId, result: eventResult }), + ); + return result; + } catch (error) { + if (active.abortController.signal.aborted || isAbortError(error)) { + this.cancelActive(active); + throw error; + } + const blockedByTurn = this._compacting === active && active.blockedByTurn; + if (this._compacting === active) { + this.cancelActive(active); + } + if (blockedByTurn) { + throw error; + } + void this.dispatcher.dispatch( + new AgentErrorEvent({ ...toKimiErrorPayload(error), agentId: this.agent.agentId }), + ); + throw error; + } finally { + try { + this._onDidFinishCompaction.fire(active); + } finally { + active.quiescence?.dispose(); + } + } + } + + private async compactionRound( + active: ActiveCompaction, + data: Readonly, + ): Promise { + const startedAt = Date.now(); + const originalHistory = [...this.context.get()]; + const tokensBefore = this.requestTokens(originalHistory); + let retryCount = 0; + let thinkingEffort = this.profile.data().thinkingLevel; + + try { + const signal = active.abortController.signal; + signal.throwIfAborted(); + + await this.hooks.onWillCompact.run(active); + + const resolvedModel = this.profile.resolveModelContext(); + thinkingEffort = resolvedModel.thinkingLevel; + const maxContextTokens = resolvedModel.modelCapabilities.max_context_tokens; + const defaultCompactionCap = + maxContextTokens > 0 + ? Math.min(maxContextTokens, DEFAULT_COMPACTION_MAX_COMPLETION_TOKENS) + : undefined; + const compactionMaxOutputSize = resolvedModel.maxOutputSize ?? defaultCompactionCap; + + const instruction = renderCompactionInstruction({ customInstruction: data.instruction }); + + const maxAttempts = resolvedModel.compactionMaxAttempts ?? MAX_COMPACTION_RETRY_ATTEMPTS; + let attempt: CompactionAttemptResult | undefined; + let historyForModel: readonly ContextMessage[] = stripDynamicToolContext(originalHistory); + let droppedCount = 0; + let overflowShrinkCount = 0; + let requestAttempts = 0; + while (true) { + const messagesToCompact = historyForModel; + const messages: Message[] = [...messagesToCompact, createUserMessage(instruction)]; + const estimatedCompactionRequestTokens = this.requestTokens(messages); + requestAttempts += 1; + + try { + const runRequest = async () => { + const request = this.llmRequester.start( + { + messages, + maxOutputSize: compactionMaxOutputSize, + source: { + type: 'operation', + turnId: active.originTurnId, + requestKind: 'full_compaction', + logFields: { droppedCount }, + }, + }, + undefined, + signal, + ); + active.trace = request.trace; + return request.result; + }; + const result = await runWithCredentialRecovery( + this.llmRequester.currentCredentialProvider(), + runRequest, + signal, + ); + attempt = collectSummary(result); + break; + } catch (error) { + const isContextOverflow = this.shouldRecoverFromContextOverflow( + error, + estimatedCompactionRequestTokens, + ); + if (isContextOverflow) { + this.observeContextOverflow(estimatedCompactionRequestTokens); + overflowShrinkCount += 1; + if ( + overflowShrinkCount > MAX_COMPACTION_OVERFLOW_SHRINK_ATTEMPTS || + requestAttempts >= maxAttempts || + messagesToCompact.length <= 1 + ) { + throw error; + } + const before = messagesToCompact.length; + historyForModel = shrinkCompactionHistoryAfterOverflow( + messagesToCompact, + overflowShrinkCount, + (message) => this.tokenCounting.estimateMessage(message), + ); + if (historyForModel.length === 0) throw error; + droppedCount += before - historyForModel.length; + retryCount = 0; + continue; + } + const unwrappedError = unwrapErrorCause(error); + if ( + (error instanceof CompactionTruncatedError || + (unwrappedError instanceof APIEmptyResponseError && + unwrappedError.finishReason !== 'filtered')) && + messagesToCompact.length > 1 + ) { + if (requestAttempts >= maxAttempts) { + throw error; + } + const reduced = dropOldestMessageAndLeadingToolResults(messagesToCompact); + droppedCount += messagesToCompact.length - reduced.length; + historyForModel = reduced; + retryCount = 0; + continue; + } + if (!isRetryableGenerateError(unwrappedError)) { + throw error; + } + if (requestAttempts >= maxAttempts) { + throw error; + } + await sleepForRetry(retryBackoffDelay(retryCount), signal); + retryCount += 1; + } + } + + if (attempt === undefined) { + throw new APIEmptyResponseError( + 'The compaction response did not contain a usable summary.', + ); + } + + if (!historySafeToCompact(this.context.get(), originalHistory)) { + const active = this._compacting; + if (active !== null) { + this.cancelActive(active); + } + throw compactionCancelledReason(active); + } + + const summary = await this.postProcessSummary(attempt.summary); + const wireLines = await this.captureWireLines(); + const recoveryFooter = this.renderRecoveryFooter(wireLines); + const summaryText = buildCompactionSummaryText(summary); + const result = this.context.applyCompaction({ + summary, + contextSummary: + recoveryFooter === undefined ? summaryText : `${summaryText}\n\n${recoveryFooter}`, + compactedCount: originalHistory.length, + tokensBefore, + summaryOutputTokens: + attempt.usage === null + ? undefined + : attempt.usage.output + + (recoveryFooter === undefined ? 0 : this.tokenCounting.estimateText(recoveryFooter)), + requestOverheadTokens: this.requestTokens([]), + droppedCount: droppedCount === 0 ? undefined : droppedCount, + wireLines, + }); + + const properties: CompactionFinishedEvent = { + turn_id: active.originTurnId, + source: data.source, + tokens_before: result.tokensBefore, + tokens_after: result.tokensAfter, + duration_ms: Date.now() - startedAt, + compacted_count: result.compactedCount, + dropped_count: result.droppedCount, + retry_count: retryCount, + round: 1, + thinking_effort: thinkingEffort, + trace_id: attempt.traceId, + ...usageTelemetry(attempt.usage), + }; + this.telemetry.track2('compaction_finished', properties); + return result; + } catch (error) { + if (isAbortError(error)) throw error; + const properties: CompactionFailedEvent = { + turn_id: active.originTurnId, + source: data.source, + tokens_before: tokensBefore, + duration_ms: Date.now() - startedAt, + round: 1, + retry_count: retryCount, + thinking_effort: thinkingEffort, + error_type: error instanceof Error ? error.name : 'Unknown', + trace_id: findAPIStatusError(error)?.traceId ?? active.traceId, + }; + this.telemetry.track2('compaction_failed', properties); + if ( + isError2(error) && + (error.code === ErrorCodes.AUTH_LOGIN_REQUIRED || + error.code === ErrorCodes.PROVIDER_AUTH_ERROR) + ) { + throw error; + } + throw new Error2(ErrorCodes.COMPACTION_FAILED, String(error), { cause: error }); + } + } + + private async postProcessSummary(summary: string): Promise { + const todos = this.todo.get(); + if (todos.length === 0) { + return summary; + } + return `${summary.trim()}\n\n${renderTodoList(todos, '## TODO List')}`; + } + + private async captureWireLines(): Promise { + try { + await this.wire.flush(); + } catch (error) { + onUnexpectedError(error); + return undefined; + } + const end = this.wire.lineCount(); + const previous = this.states.get(fullCompactionWireRangesKey).at(-1); + const start = Math.max(previous?.end ?? 0, this.wire.lastContextClearLine() ?? 0) + 1; + if (end < start) return undefined; + return { start, end }; + } + + private renderRecoveryFooter(wireLines: WireLineRange | undefined): string | undefined { + if (wireLines === undefined) return undefined; + const journalPath = this.wire.journalPath(); + if (journalPath === undefined) return undefined; + const windows = [...this.states.get(fullCompactionWireRangesKey), wireLines]; + return renderContextRecoveryPointer({ journalPath, windows }); + } + + private tokenCountWithPending(): number { + return this.tokenCounting.get(agentContextOfScope(this.agent)).size; + } +} + +function findAPIStatusError(error: unknown): APIStatusError | undefined { + let current: unknown = error; + const seen = new Set(); + while (current !== undefined && current !== null && !seen.has(current)) { + if (current instanceof APIStatusError) return current; + seen.add(current); + current = current instanceof Error ? current.cause : undefined; + } + return undefined; +} + +function collectSummary(finish: AgentLLMRequestFinish): CompactionAttemptResult { + if (finish.providerFinishReason === 'truncated') { + throw new CompactionTruncatedError(); + } + + const summary = finish.message.content + .filter((part) => part.type === 'text') + .map((part) => part.text) + .join('') + .trim(); + if (summary.length === 0) { + throw new APIEmptyResponseError( + 'The compaction response did not contain a non-empty summary.', + ); + } + + return { summary, usage: finish.usage, traceId: finish.traceId }; +} + +function historySafeToCompact( + current: readonly ContextMessage[], + original: readonly ContextMessage[], +): boolean { + if (current.length < original.length) return false; + if (!original.every((message, index) => message === current[index])) return false; + return current.slice(original.length).every(isRealUserInput); +} + +function shrinkCompactionHistoryAfterOverflow( + messages: readonly T[], + attempt: number, + estimateMessage: (message: T) => number = estimateTokensForMessage, +): T[] { + if (messages.length <= 1) return messages.slice(); + const ratio = COMPACTION_OVERFLOW_SHRINK_RATIOS[ + Math.min(attempt - 1, COMPACTION_OVERFLOW_SHRINK_RATIOS.length - 1) + ]!; + let totalTokens = 0; + for (const message of messages) totalTokens += estimateMessage(message); + const tokenBudget = Math.floor(totalTokens * ratio); + return takeRecentMessagesWithinTokenBudget(messages, tokenBudget, estimateMessage); +} + +function takeRecentMessagesWithinTokenBudget( + messages: readonly T[], + tokenBudget: number, + estimateMessage: (message: T) => number = estimateTokensForMessage, +): T[] { + let start = messages.length; + let tokens = 0; + for (let i = messages.length - 1; i >= 0; i--) { + const messageTokens = estimateMessage(messages[i]!); + if (tokens + messageTokens > tokenBudget) break; + tokens += messageTokens; + start = i; + } + if (start === 0) start = 1; + return dropLeadingToolResults(messages.slice(start)); +} + +function dropOldestMessageAndLeadingToolResults( + messages: readonly T[], +): T[] { + if (messages.length <= 1) return messages.slice(); + return dropLeadingToolResults(messages.slice(1)); +} + +function dropLeadingToolResults(messages: readonly T[]): T[] { + let start = 0; + while (start < messages.length && messages[start]!.role === 'tool') { + start += 1; + } + return messages.slice(start); +} + +function usageTelemetry(usage: TokenUsage | null): CompactionTelemetryProperties { + if (usage === null) return {}; + return { + input_tokens: inputTotal(usage), + output_tokens: usage.output, + input_cache_read: usage.inputCacheRead, + input_cache_creation: usage.inputCacheCreation, + }; +} + +function compactionCancelledReason(active: ActiveCompaction | null): Error { + const reason = active?.abortController.signal.reason; + if (reason instanceof Error) return reason; + const error = new Error('Compaction cancelled.'); + error.name = 'AbortError'; + return error; +} + +registerScopedService( + LifecycleScope.Agent, + IAgentFullCompactionService, + AgentFullCompactionService, + ScopeActivation.OnScopeCreated, + 'fullCompaction', +); diff --git a/packages/agent-core-v2/src/agent/fullCompaction/strategy.ts b/packages/agent-core-v2/src/agent/fullCompaction/strategy.ts new file mode 100644 index 0000000000000000000000000000000000000000..804e9ff8f35c87959f160226803208e8dce1cade --- /dev/null +++ b/packages/agent-core-v2/src/agent/fullCompaction/strategy.ts @@ -0,0 +1,266 @@ +import type { Message } from '#/llm-adapter/contract/message'; +import type { ProfileModelContext } from '#/agent/profile/profile'; +import type { CompactionSource } from './types'; +import { estimateTokensForMessage } from '#/llm-adapter/contract/tokens'; + +export interface CompactionConfig { + triggerRatio: number; + blockRatio: number; + reservedContextSize: number; + maxCompactionPerTurn: number; + maxOverflowCompactionAttempts: number; + maxRecentMessages: number; + maxRecentUserMessages: number; + maxRecentSizeRatio: number; + minOverflowReductionRatio: number; +} + +export const DEFAULT_COMPACTION_CONFIG: CompactionConfig = { + triggerRatio: 0.85, + blockRatio: 0.85, + reservedContextSize: 50_000, + maxCompactionPerTurn: Infinity, + maxOverflowCompactionAttempts: 3, + maxRecentMessages: 4, + maxRecentUserMessages: Infinity, + maxRecentSizeRatio: 0.2, + minOverflowReductionRatio: 0.05, +}; + +export interface CompactionStrategy { + shouldCompact(usedSize: number): boolean; + shouldBlock(usedSize: number): boolean; + computeCompactCount(messages: readonly Message[], source: CompactionSource): number; + reduceCompactOnOverflow(messages: readonly Message[]): number; + readonly checkAfterStep: boolean; + readonly maxCompactionPerTurn: number; + readonly maxOverflowCompactionAttempts: number; +} + +export class RuntimeCompactionStrategy implements CompactionStrategy { + constructor( + private readonly context: () => ProfileModelContext, + private readonly estimateMessage: (message: Message) => number = estimateTokensForMessage, + ) { } + + shouldCompact(usedSize: number): boolean { + return this.delegate().shouldCompact(usedSize); + } + + shouldBlock(usedSize: number): boolean { + return this.delegate().shouldBlock(usedSize); + } + + computeCompactCount(messages: readonly Message[], source: CompactionSource): number { + return this.windowDelegate().computeCompactCount(messages, source); + } + + reduceCompactOnOverflow(messages: readonly Message[]): number { + return this.windowDelegate().reduceCompactOnOverflow(messages); + } + + get checkAfterStep(): boolean { + return this.config().triggerRatio !== this.config().blockRatio; + } + + get maxCompactionPerTurn(): number { + return DEFAULT_COMPACTION_CONFIG.maxCompactionPerTurn; + } + + get maxOverflowCompactionAttempts(): number { + return DEFAULT_COMPACTION_CONFIG.maxOverflowCompactionAttempts; + } + + private delegate(): DefaultCompactionStrategy { + const model = this.context(); + return new DefaultCompactionStrategy( + () => model.modelCapabilities.max_input_tokens ?? model.modelCapabilities.max_context_tokens, + this.config(model), + this.estimateMessage, + ); + } + + private windowDelegate(): DefaultCompactionStrategy { + return new DefaultCompactionStrategy( + () => this.context().modelCapabilities.max_input_tokens ?? this.context().modelCapabilities.max_context_tokens, + DEFAULT_COMPACTION_CONFIG, + this.estimateMessage, + ); + } + + private config(model: ProfileModelContext = this.context()): CompactionConfig { + const triggerRatio = model.compactionTriggerRatio ?? DEFAULT_COMPACTION_CONFIG.triggerRatio; + const blockRatio = Math.max(triggerRatio, DEFAULT_COMPACTION_CONFIG.blockRatio); + return { + ...DEFAULT_COMPACTION_CONFIG, + triggerRatio, + blockRatio, + reservedContextSize: + model.reservedContextSize ?? DEFAULT_COMPACTION_CONFIG.reservedContextSize, + }; + } +} + + +export class DefaultCompactionStrategy implements CompactionStrategy { + constructor( + protected readonly maxSizeProvider: () => number, + protected readonly config: CompactionConfig = DEFAULT_COMPACTION_CONFIG, + protected readonly estimateMessage: (message: Message) => number = estimateTokensForMessage, + ) { } + + protected get maxSize(): number { + return this.maxSizeProvider(); + } + + shouldCompact(usedSize: number): boolean { + if (this.maxSize <= 0) return false; + return ( + usedSize >= this.maxSize * this.config.triggerRatio || + this.shouldUseReservedContext(usedSize) + ); + } + + shouldBlock(usedSize: number): boolean { + if (this.maxSize <= 0) return false; + return ( + usedSize >= this.maxSize * this.config.blockRatio || + this.shouldUseReservedContext(usedSize) + ); + } + + private shouldUseReservedContext(usedSize: number): boolean { + const reservedSize = this.config.reservedContextSize; + return reservedSize > 0 && reservedSize < this.maxSize && usedSize + reservedSize >= this.maxSize; + } + + computeCompactCount(messages: readonly Message[], source: CompactionSource): number { + + if (source === 'manual') { + for (let i = messages.length - 1; i > 0; i--) { + if (canSplitAfter(messages, i)) { + return this.fitCompactCountToWindow(messages, i + 1); + } + } + return 0; + } + + + let recentMessages = 1; + let recentUserMessages = 0; + let recentSize = 0; + let bestN: number | undefined; + + for (; recentMessages < messages.length; recentMessages++) { + const splitIndex = messages.length - recentMessages - 1; + const m2 = messages[messages.length - recentMessages]!; + + if (m2.role === 'user') { + recentUserMessages++; + } + recentSize += this.estimateMessage(m2); + + if (canSplitAfter(messages, splitIndex)) { + bestN = splitIndex + 1; + } + + const reachesMax = recentMessages >= this.config.maxRecentMessages + || recentUserMessages >= this.config.maxRecentUserMessages + || recentSize >= this.maxSize * this.config.maxRecentSizeRatio; + if (reachesMax && bestN !== undefined) { + break; + } + } + + return this.fitCompactCountToWindow(messages, bestN ?? 0); + } + + reduceCompactOnOverflow(messages: readonly Message[]): number { + const minReducedSize = Math.max( + 1, + Math.ceil(this.maxSize * this.config.minOverflowReductionRatio), + ); + let reducedSize = 0; + let bestN: number | undefined; + + for (let i = messages.length - 2; i > 0; i--) { + reducedSize += this.estimateMessage(messages[i + 1]!); + if (canSplitAfter(messages, i)) { + bestN = i + 1; + if (reducedSize >= minReducedSize) { + return i + 1; + } + } + } + return bestN ?? messages.length; + } + + private fitCompactCountToWindow( + messages: readonly Message[], + compactedCount: number, + ): number { + if (this.maxSize <= 0 || compactedCount <= 0) { + return compactedCount; + } + + let compactedSize = 0; + for (let i = 0; i < compactedCount; i++) { + compactedSize += this.estimateMessage(messages[i]!); + } + if (compactedSize <= this.maxSize) { + return compactedCount; + } + + let bestN: number | undefined; + for (let n = compactedCount - 1; n > 0; n--) { + compactedSize -= this.estimateMessage(messages[n]!); + if (!canSplitAfter(messages, n - 1)) { + continue; + } + bestN = n; + if (compactedSize <= this.maxSize) { + return n; + } + } + + return bestN ?? compactedCount; + } + + get checkAfterStep(): boolean { + return this.config.triggerRatio !== this.config.blockRatio; + } + + get maxCompactionPerTurn(): number { + return this.config.maxCompactionPerTurn; + } + + get maxOverflowCompactionAttempts(): number { + return this.config.maxOverflowCompactionAttempts; + } +} + +function canSplitAfter(messages: readonly Message[], index: number): boolean { + const m = messages[index]; + if (m === undefined) return false; + if (m.role === 'user') return false; + if (m.role === 'assistant' && m.toolCalls.length > 0) return false; + if (messages[index + 1]?.role === 'tool') return false; + if (prefixEndsWithOpenToolExchange(messages, index)) return false; + return true; +} + +function prefixEndsWithOpenToolExchange(messages: readonly Message[], index: number): boolean { + if (messages[index]?.role !== 'tool') return false; + + let toolResultCount = 0; + for (let i = index; i >= 0; i--) { + const message = messages[i]; + if (message === undefined) return false; + if (message.role === 'tool') { + toolResultCount++; + continue; + } + return message.role === 'assistant' && message.toolCalls.length > toolResultCount; + } + return false; +} diff --git a/packages/agent-core-v2/src/agent/fullCompaction/types.ts b/packages/agent-core-v2/src/agent/fullCompaction/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..14930a6f7a34e927c7eb79fbf79e9386651e5d5a --- /dev/null +++ b/packages/agent-core-v2/src/agent/fullCompaction/types.ts @@ -0,0 +1,17 @@ +export interface CompactionResult { + summary: string; + contextSummary?: string; + compactedCount: number; + tokensBefore: number; + tokensAfter: number; + keptUserMessageCount?: number; + keptHeadUserMessageCount?: number; + droppedCount?: number; +} + +export type CompactionSource = 'manual' | 'auto'; + +export interface CompactionBeginData { + instruction?: string; + source: CompactionSource; +} diff --git a/packages/agent-core-v2/src/agent/interaction/approval.ts b/packages/agent-core-v2/src/agent/interaction/approval.ts new file mode 100644 index 0000000000000000000000000000000000000000..f50aa886a015a2f00fae3a196e5cf7d194e0388f --- /dev/null +++ b/packages/agent-core-v2/src/agent/interaction/approval.ts @@ -0,0 +1,21 @@ +import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; + +export type ApprovalDecision = 'approved' | 'rejected' | 'cancelled'; + +export interface ApprovalRequest { + readonly id?: string; + readonly sessionId?: string; + readonly agentId?: string; + readonly turnId?: number; + readonly toolCallId?: string; + readonly toolName: string; + readonly action: string; + readonly display: ToolInputDisplay; +} + +export interface ApprovalResponse { + readonly decision: ApprovalDecision; + readonly scope?: 'session'; + readonly feedback?: string; + readonly selectedLabel?: string; +} diff --git a/packages/agent-core-v2/src/agent/interaction/interactionOps.ts b/packages/agent-core-v2/src/agent/interaction/interactionOps.ts new file mode 100644 index 0000000000000000000000000000000000000000..c7154d706b7cf930b4249db436cb35d58e7d6ae7 --- /dev/null +++ b/packages/agent-core-v2/src/agent/interaction/interactionOps.ts @@ -0,0 +1,50 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { z } from 'zod'; + +import { AgentEvent2, registerEvent2Class } from '#/app/event/event2'; +import type { InteractionKind } from '#/human/interaction/interaction'; + +const interactionRequestSchema = z.object({ + agentId: z.string(), + id: z.string(), + kind: z.enum(['approval', 'question', 'user_tool']), + toolCallId: z.string().optional(), + request: z.unknown(), +}); + +export class InteractionRequestEvent extends AgentEvent2< + z.infer +> { + static override readonly type = 'interaction.request'; + static override readonly durable = true; + static override readonly schema = interactionRequestSchema; +} +export interface InteractionRequestEvent { + readonly agentId: string; + readonly id: string; + readonly kind: InteractionKind; + readonly toolCallId?: string; + readonly request: unknown; +} + +const interactionResolvedSchema = z.object({ + agentId: z.string(), + id: z.string(), + response: z.unknown(), +}); + +export class InteractionResolvedEvent extends AgentEvent2< + z.infer +> { + static override readonly type = 'interaction.resolved'; + static override readonly durable = true; + static override readonly schema = interactionResolvedSchema; +} +export interface InteractionResolvedEvent { + readonly agentId: string; + readonly id: string; + readonly response: unknown; +} + +registerEvent2Class(InteractionRequestEvent); +registerEvent2Class(InteractionResolvedEvent); diff --git a/packages/agent-core-v2/src/agent/interaction/interactionWiring.ts b/packages/agent-core-v2/src/agent/interaction/interactionWiring.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d93e2c1f7b6316abc49641df5d5fd34ff1d1207 --- /dev/null +++ b/packages/agent-core-v2/src/agent/interaction/interactionWiring.ts @@ -0,0 +1,83 @@ +import { + INTERACTION_TAG_AGENT_ID, + INTERACTION_TAG_SESSION_ID, + INTERACTION_TAG_TOOL_CALL_ID, + type InteractionCancellation, + type InteractionTags, +} from '#/human/interaction/interaction'; +import { interactions } from '#/human/interaction/facade'; +import type { InteractionEmitted } from '#/human/interaction/machine'; +import type { IEventDispatcher } from '#/state/eventDispatcher'; + +import { InteractionRequestEvent, InteractionResolvedEvent } from './interactionOps'; + +export function attachInteractionAgent( + agentId: string, + sessionId: string, + dispatcher: IEventDispatcher, +): void { + interactions.attachAgent(agentId, sessionId, (emitted) => { + dispatchInteractionEvent(agentId, emitted, dispatcher); + }); +} + +export function detachInteractionAgent(agentId: string, sessionId: string): void { + interactions.detachAgent(agentId, sessionId); + for (const interaction of interactions.findAll({ + resolved: false, + tags: { [INTERACTION_TAG_AGENT_ID]: agentId, [INTERACTION_TAG_SESSION_ID]: sessionId }, + })) { + const response: InteractionCancellation = { cancelled: true, reason: 'agent_closed' }; + interactions.respond(interaction.id, response); + } +} + +export function cancelInteractionsForTurn(agentId: string, sessionId: string, turnId: number): void { + for (const interaction of interactions.findAll({ + resolved: false, + tags: { + [INTERACTION_TAG_AGENT_ID]: agentId, + [INTERACTION_TAG_SESSION_ID]: sessionId, + turnId, + }, + })) { + const response: InteractionCancellation = { cancelled: true, reason: 'turn_ended' }; + interactions.respond(interaction.id, response); + } +} + +function dispatchInteractionEvent( + agentId: string, + emitted: InteractionEmitted, + dispatcher: IEventDispatcher, +): void { + if (emitted.type === 'interaction.requested') { + const record = emitted.record; + void dispatcher.dispatch( + new InteractionRequestEvent({ + agentId, + id: record.id, + kind: record.kind, + toolCallId: + readStringTag(record.tags, INTERACTION_TAG_TOOL_CALL_ID) ?? + readPayloadToolCallId(record.payload), + request: record.payload, + }), + ); + return; + } + void dispatcher.dispatch( + new InteractionResolvedEvent({ agentId, id: emitted.id, response: emitted.response }), + ); +} + +function readStringTag(tags: InteractionTags, key: string): string | undefined { + const value = tags[key]; + return typeof value === 'string' ? value : undefined; +} + +function readPayloadToolCallId(payload: unknown): string | undefined { + if (typeof payload !== 'object' || payload === null) return undefined; + const value = (payload as Record)['toolCallId']; + return typeof value === 'string' ? value : undefined; +} diff --git a/packages/agent-core-v2/src/agent/interaction/question.ts b/packages/agent-core-v2/src/agent/interaction/question.ts new file mode 100644 index 0000000000000000000000000000000000000000..95faa25a227c695858f8d12af4d6f3b362c949b1 --- /dev/null +++ b/packages/agent-core-v2/src/agent/interaction/question.ts @@ -0,0 +1,32 @@ +export interface QuestionOption { + readonly label: string; + readonly description?: string; +} + +export interface QuestionItem { + readonly question: string; + readonly header?: string; + readonly body?: string; + readonly options: readonly QuestionOption[]; + readonly multiSelect?: boolean; + readonly otherLabel?: string; + readonly otherDescription?: string; +} + +export type QuestionAnswerMethod = 'enter' | 'space' | 'number_key'; + +export type QuestionAnswers = Record; + +export interface QuestionResponse { + readonly answers: QuestionAnswers; + readonly method?: QuestionAnswerMethod; +} + +export type QuestionResult = null | QuestionAnswers | QuestionResponse; + +export interface QuestionRequest { + readonly id?: string; + readonly turnId?: number; + readonly toolCallId?: string; + readonly questions: readonly QuestionItem[]; +} diff --git a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminder.ts b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminder.ts new file mode 100644 index 0000000000000000000000000000000000000000..5dfb78d603c9b1c339232a0c74a4dfde638e81da --- /dev/null +++ b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminder.ts @@ -0,0 +1,8 @@ +import { createDecorator } from '#/_base/di/instantiation'; + +export interface IAgentInterruptionReminderService { + readonly _serviceBrand: undefined; +} + +export const IAgentInterruptionReminderService = + createDecorator('agentInterruptionReminderService'); diff --git a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts new file mode 100644 index 0000000000000000000000000000000000000000..2ff8efcb7e877be7fe046c329d7d0772f648cf5b --- /dev/null +++ b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts @@ -0,0 +1,33 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { z } from 'zod'; + +import { AgentEvent2 } from '#/app/event/event2'; +import { defineState } from '#/state/state'; + +export const INTERRUPTION_REMINDER_VARIANT = 'interruption'; + +export type InterruptionReminderState = null; + +const interruptionReminderRecordedSchema = z.object({ + agentId: z.string(), + turnId: z.number().int().nonnegative(), +}); + +export class InterruptionReminderRecorded extends AgentEvent2< + z.infer +> { + static override readonly type = 'interruptionReminder.recorded'; + static override readonly durable = true; + static override readonly schema = interruptionReminderRecordedSchema; +} +export interface InterruptionReminderRecorded { + readonly agentId: string; + readonly turnId: number; +} + +export const interruptionReminderKey = defineState( + 'interruptionReminder', + (): InterruptionReminderState => null, +) + .replayable({ schema: z.custom() }) + .on(InterruptionReminderRecorded, () => {}); diff --git a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts new file mode 100644 index 0000000000000000000000000000000000000000..acea65627a3088e56d3687bc9767be2ee2a08729 --- /dev/null +++ b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts @@ -0,0 +1,70 @@ +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent'; +import { TurnEnded } from '#/agent/loop/turnOps'; +import { IAgentReminderService } from '#/features/reminder/reminderService'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { IEventBus } from '#/app/event/eventBus'; + +import { IAgentInterruptionReminderService } from './interruptionReminder'; +import { INTERRUPTION_REMINDER_VARIANT, interruptionReminderKey } from './interruptionReminderOps'; + +const INTERRUPTION_REMINDER = [ + 'The previous turn was interrupted by the user before completion;', + 'any partial output shown above is incomplete.', + "The user's next message continues the conversation.", +].join(' '); + +export class AgentInterruptionReminderService + extends Disposable + implements IAgentInterruptionReminderService +{ + declare readonly _serviceBrand: undefined; + + constructor( + @IEventBus eventBus: IEventBus, + @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + @IAgentReminderService private readonly reminder: IAgentReminderService, + @IAgentStateService agentState: IAgentStateService, + ) { + super(); + agentState.contributeState(interruptionReminderKey); + this._register( + eventBus.subscribe(TurnEnded, (event) => { + if (event.reason !== 'cancelled' || event.interruptReason !== 'user_cancelled') return; + const origin = lastComparableMessage(this.context.get())?.origin; + if (origin?.kind === 'injection' && origin.variant === INTERRUPTION_REMINDER_VARIANT) return; + this.reminder.notify(INTERRUPTION_REMINDER, { + variant: INTERRUPTION_REMINDER_VARIANT, + }); + }), + ); + } +} + +function lastComparableMessage(messages: readonly ContextMessage[]): ContextMessage | undefined { + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]!; + if ( + message.role === 'assistant' && + message.partial === true && + message.toolCalls.length === 0 && + message.content.every(isVacuousContentPart) + ) { + continue; + } + return message; + } + return undefined; +} + +registerScopedService( + LifecycleScope.Agent, + IAgentInterruptionReminderService, + AgentInterruptionReminderService, + ScopeActivation.OnScopeCreated, + 'interruptionReminder', +); diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts new file mode 100644 index 0000000000000000000000000000000000000000..45771b2c04276860f999e98c0dbb98ee880d59c9 --- /dev/null +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts @@ -0,0 +1,107 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { z } from 'zod'; + +import { AgentEvent2 } from '#/app/event/event2'; +import type { ThinkingEffort } from '#human/llm/thinking'; +import { defineState } from '#/state/state'; + +export interface LlmRequestToolSchema { + readonly name: string; + readonly description: string; + readonly parameters: Record; +} + +export interface LlmRequestTraceState { + readonly seenToolsHashes: readonly string[]; +} + +const llmToolEntrySchema = z.object({ + name: z.string(), + description: z.string(), + parameters: z.record(z.string(), z.unknown()), +}); + +const llmToolsSnapshotSchema = z.object({ + agentId: z.string(), + hash: z.string(), + tools: z.array(llmToolEntrySchema).readonly(), +}); + +export class LlmToolsSnapshot extends AgentEvent2> { + static override readonly type = 'llm.tools_snapshot'; + static override readonly durable = true; + static override readonly schema = llmToolsSnapshotSchema; +} +export interface LlmToolsSnapshot { + readonly agentId: string; + readonly hash: string; + readonly tools: readonly LlmRequestToolSchema[]; +} + +const llmRequestSchema = z.object({ + agentId: z.string(), + kind: z.enum(['loop', 'compaction']), + provider: z.string(), + model: z.string(), + modelAlias: z.string().optional(), + thinkingEffort: z.custom().optional(), + thinkingKeep: z.string().optional(), + temperature: z.number().optional(), + topP: z.number().optional(), + maxTokens: z.number().optional(), + betaApi: z.boolean().optional(), + toolSelect: z.boolean(), + systemPromptHash: z.string(), + systemPrompt: z.string().optional(), + toolsHash: z.string(), + messageCount: z.number(), + turnStep: z.string().optional(), + attempt: z.string().optional(), + projection: z.enum(['strict', 'media-degraded', 'media-stripped', 'strict-media-degraded', 'strict-media-stripped']).optional(), + droppedCount: z.number().optional(), +}); + +export type LlmRequestPayload = z.infer; + +export class LlmRequest extends AgentEvent2 { + static override readonly type = 'llm.request'; + static override readonly durable = true; + static override readonly schema = llmRequestSchema; +} +export interface LlmRequest { + readonly agentId: string; + readonly kind: 'loop' | 'compaction'; + readonly provider: string; + readonly model: string; + readonly modelAlias?: string; + readonly thinkingEffort?: ThinkingEffort; + readonly thinkingKeep?: string; + readonly temperature?: number; + readonly topP?: number; + readonly maxTokens?: number; + readonly betaApi?: boolean; + readonly toolSelect: boolean; + readonly systemPromptHash: string; + readonly systemPrompt?: string; + readonly toolsHash: string; + readonly messageCount: number; + readonly turnStep?: string; + readonly attempt?: string; + readonly projection?: + | 'strict' + | 'media-degraded' + | 'media-stripped' + | 'strict-media-degraded' + | 'strict-media-stripped'; + readonly droppedCount?: number; +} + +export const llmRequestTraceKey = defineState( + 'llm.requestTrace', + (): LlmRequestTraceState => ({ seenToolsHashes: [] }), +).replayable({ schema: z.custom() }) + .on(LlmToolsSnapshot, (s, e) => { + if (s.seenToolsHashes.includes(e.hash)) return; + s.seenToolsHashes = [...s.seenToolsHashes, e.hash]; + }) + .on(LlmRequest, () => {}); diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts new file mode 100644 index 0000000000000000000000000000000000000000..470645fd871888fa98c645313d068e0c762a1231 --- /dev/null +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts @@ -0,0 +1,83 @@ +import { createDecorator } from '#/_base/di/instantiation'; +import type { FinishReason } from '#human/llm/finish-reason'; +import type { LlmCredentialProvider } from '#human/llm/requester/requester'; +import type { ThinkingEffort } from '#human/llm/thinking'; +import type { Message } from '#/llm-adapter/contract/message'; +import type { StreamedMessagePart, ToolDescription as Tool } from '#human/llm/message'; +import type { TokenUsage } from '#human/llm/usage'; +import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; +import type { ModelRequestTiming } from '#/llm-adapter/model/model-requester'; +import type { LogContext } from '#/_base/log/log'; + +export type AgentLLMRequestLogFields = Readonly; + +export type AgentLLMRequestSource = + | { + readonly type: 'turn'; + readonly turnId: number; + readonly step?: number; + readonly logFields?: AgentLLMRequestLogFields; + } + | { + readonly type: 'operation'; + readonly turnId?: number; + readonly requestKind?: string; + readonly logFields?: AgentLLMRequestLogFields; + }; + +export interface AgentLLMRequestFinish { + message: Message; + usage: TokenUsage; + model?: string | undefined; + providerFinishReason?: FinishReason; + rawFinishReason?: string; + providerMessageId?: string; + timing?: ModelRequestTiming; + traceId?: string; +} + +export type AgentLLMRequestPartHandler = (part: StreamedMessagePart) => void | Promise; + +export interface AgentLLMRequestOverrides { + messages?: readonly Message[]; + tools?: readonly Tool[]; + systemPrompt?: string; + source?: AgentLLMRequestSource; + maxOutputSize?: number; + onAttemptRetry?: () => void; +} + +export interface AgentLLMRequestTask { + readonly trace: LLMRequestTrace; + readonly result: Promise; +} + +export interface PreparedTurnRequestConfig { + readonly thinkingEffort: ThinkingEffort; +} + +export interface IAgentLLMRequesterService { + readonly _serviceBrand: undefined; + + prepareTurnConfig(turnId: number): PreparedTurnRequestConfig | undefined; + + currentCredentialProvider(): LlmCredentialProvider | undefined; + + credentialProviderForTurn(turnId: number): LlmCredentialProvider | undefined; + + request( + overrides?: AgentLLMRequestOverrides, + onPart?: AgentLLMRequestPartHandler, + signal?: AbortSignal, + ): Promise; + + start( + overrides?: AgentLLMRequestOverrides, + onPart?: AgentLLMRequestPartHandler, + signal?: AbortSignal, + ): AgentLLMRequestTask; +} + +export const IAgentLLMRequesterService = createDecorator( + 'agentLLMRequesterService', +); diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts new file mode 100644 index 0000000000000000000000000000000000000000..63dd66a0dca24572f3ae7724df6f9d66e393f757 --- /dev/null +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -0,0 +1,994 @@ +import { createHash } from 'node:crypto'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { defineState } from '#/state/state'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { + IAgentContextProjectorService, + type MediaStripSnapshot, + type ProjectionPolicy, +} from '#/agent/contextProjector/contextProjector'; +import { ISessionTokenCountingService } from '#/session/tokenCounting/sessionTokenCounting'; +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 { IAgentMediaResolverService } from '#/agent/media/mediaResolver'; +import { ISessionUsageService } from '#/session/usage/sessionUsage'; +import { IConfigService } from '#/app/config/config'; +import { + APIContextOverflowError, + APIRequestTooLargeError, + APIStatusError, + classifyApiError, + isImageFormatError, + isRecoverableRequestStructureError, + isRetryableGenerateError, +} from '#/llm-adapter/contract/errors'; +import type { Message } from '#/llm-adapter/contract/message'; +import { type ThinkingEffort } from '#human/llm/thinking'; +import type { LlmCredentialProvider } from '#human/llm/requester/requester'; +import { isToolCall, type StreamedMessagePart, type ToolDescription as Tool } from '#human/llm/message'; +import { emptyUsage, inputTotal, type TokenUsage } from '#human/llm/usage'; +import { ILogService, type LogContext } from '#/_base/log/log'; +import { IModelCatalog, type Model } from '#/llm-adapter/model/catalog'; +import { + effectiveMaxCompletionTokens, + type ModelRequestEvent, + type ModelRequestParams, + type ModelRequester, + type ModelRequestTiming, +} from '#/llm-adapter/model/model-requester'; +import type { ModelOverrides } from '#/llm-adapter/model/model.types'; +import { IModelService } from '#/llm-adapter/model/model'; +import { completionBudgetParams, resolveCompletionBudget } from '#/llm-adapter/model/completion-budget'; +import { resolveThinkingKeep, type ThinkingConfig } from '#/llm-adapter/model/thinking'; +import { THINKING_SECTION } from '#/app/kosongConfig/configSection'; +import type { Protocol } from '#/llm-adapter/protocol/protocol'; +import type { + ApiErrorEvent, + LlmRequestProjectionFallbackEvent, +} from '#/app/telemetry/events'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IEventDispatcher } from '#/state/eventDispatcher'; +import { WarningIssued } from '#/agent/profile/profileOps'; + +import { + IAgentLLMRequesterService, + type AgentLLMRequestFinish, + type AgentLLMRequestLogFields, + type AgentLLMRequestOverrides, + type AgentLLMRequestPartHandler, + type AgentLLMRequestSource, + type AgentLLMRequestTask, + type PreparedTurnRequestConfig, +} from './llmRequester'; +import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; +import { + ToolCallIdNormalizer, + type ToolCallIdResponseNormalizer, +} from '#human/llm/toolCallIdNormalizer'; +import { + LlmRequest, + llmRequestTraceKey, + LlmToolsSnapshot, + type LlmRequestPayload, + type LlmRequestToolSchema, +} from './llmRequestOps'; +import { isAbortError } from '#/_base/utils/abort'; +import { parseBooleanEnv } from '#/_base/utils/env'; +import { ErrorCodes, Error2, unwrapErrorCause } from '#/errors'; +import { + readRetryAfterMs, + retryBackoffDelay, + retryErrorFields, + sleepForRetry, +} from '#/_base/utils/retry'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; + +const EMPTY_TOOL_PARAMETERS: Record = { + type: 'object', + properties: {}, +}; + +const noopOnPart: AgentLLMRequestPartHandler = () => {}; + +export const KIMI_CODE_INFINITE_RETRY_ENV = 'KIMI_CODE_INFINITE_RETRY'; + +interface ResolvedLLMRequest { + readonly requester: ModelRequester; + readonly model: Model; + readonly params: ModelRequestParams; + readonly modelAlias: string; + readonly thinkingEffort: ThinkingEffort; + readonly systemPrompt: string; + readonly tools: readonly Tool[]; + readonly messages: Message[]; + readonly source: AgentLLMRequestSource | undefined; + readonly logFields: AgentLLMRequestLogFields; +} + +interface LLMRequestLogInput { + readonly protocol: Protocol; + readonly providerType?: string; + readonly modelName: string; + readonly modelAlias?: string; + readonly thinkingEffort?: ThinkingEffort | null; + readonly maxTokens?: number; + readonly systemPrompt: string; + readonly tools: readonly Tool[]; + readonly messages: readonly Message[]; + readonly fields?: AgentLLMRequestLogFields; +} + +interface TurnRequestConfig { + readonly resolved: ProfileModelContext; + readonly params: ModelRequestParams; + readonly systemPrompt: string; +} + +export const llmRequesterLastConfigLogSignatureKey = defineState( + 'llmRequester.lastConfigLogSignature', + () => undefined as string | undefined, +); +export const llmRequesterTurnConfigsKey = defineState>( + 'llmRequester.turnConfigs', + () => new Map(), +); +export const llmRequesterMediaDegradedTurnsKey = defineState>( + 'llmRequester.mediaDegradedTurns', + () => new Set(), +); +export const llmRequesterMediaStrippedTurnsKey = defineState>( + 'llmRequester.mediaStrippedTurns', + () => new Map(), +); +export const llmRequesterEmittedThinkingEffortWarningsKey = defineState>( + 'llmRequester.emittedThinkingEffortWarnings', + () => new Set(), +); + +export class AgentLLMRequesterService implements IAgentLLMRequesterService { + declare readonly _serviceBrand: undefined; + + private readonly toolCallIdNormalizer = new ToolCallIdNormalizer(); + + constructor( + @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + @IAgentContextProjectorService private readonly projector: IAgentContextProjectorService, + @ISessionTokenCountingService private readonly tokenCounting: ISessionTokenCountingService, + @IAgentToolRegistryService private readonly tools: IAgentToolRegistryService, + @IAgentToolSelectService private readonly toolSelect: IAgentToolSelectService, + @IAgentMediaResolverService private readonly mediaResolver: IAgentMediaResolverService, + @IAgentProfileService private readonly profile: IAgentProfileService, + @ISessionUsageService private readonly usage: ISessionUsageService, + @IConfigService private readonly config: IConfigService, + @IModelService private readonly modelService: IModelService, + @IModelCatalog private readonly modelCatalog: IModelCatalog, + @ILogService private readonly log: ILogService, + @ITelemetryService private readonly telemetry: ITelemetryService, + @IEventDispatcher private readonly dispatcher: IEventDispatcher, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @IAgentStateService private readonly states: IAgentStateService, + @IBootstrapService private readonly bootstrap: IBootstrapService, + ) { + this.states.contributeState(llmRequestTraceKey); + this.states.contributeState(llmRequesterLastConfigLogSignatureKey); + this.states.contributeState(llmRequesterTurnConfigsKey); + this.states.contributeState(llmRequesterMediaDegradedTurnsKey); + this.states.contributeState(llmRequesterMediaStrippedTurnsKey); + this.states.contributeState(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 { + return this.states.get(llmRequesterTurnConfigsKey); + } + + private get mediaDegradedTurns(): Set { + return this.states.get(llmRequesterMediaDegradedTurnsKey); + } + + private get mediaStrippedTurns(): Map { + return this.states.get(llmRequesterMediaStrippedTurnsKey); + } + + private get emittedThinkingEffortWarnings(): Set { + return this.states.get(llmRequesterEmittedThinkingEffortWarningsKey); + } + + prepareTurnConfig(turnId: number): PreparedTurnRequestConfig | undefined { + if (!this.profile.hasProvider()) return undefined; + const config = this.getOrCreateTurnConfig(turnId); + return { thinkingEffort: config.resolved.thinkingLevel }; + } + + currentCredentialProvider(): LlmCredentialProvider | undefined { + if (!this.profile.hasProvider()) return undefined; + return this.modelCatalog.get(this.profile.resolveModelContext().modelAlias).credentialProvider; + } + + credentialProviderForTurn(turnId: number): LlmCredentialProvider | undefined { + if (!this.profile.hasProvider()) return undefined; + const resolved = this.turnConfigs.get(turnId)?.resolved ?? this.profile.resolveModelContext(); + return this.modelCatalog.get(resolved.modelAlias).credentialProvider; + } + + async request( + overrides: AgentLLMRequestOverrides = {}, + onPart: AgentLLMRequestPartHandler = noopOnPart, + signal?: AbortSignal, + ): Promise { + return this.start(overrides, onPart, signal).result; + } + + start( + overrides: AgentLLMRequestOverrides = {}, + onPart: AgentLLMRequestPartHandler = noopOnPart, + signal?: AbortSignal, + ): AgentLLMRequestTask { + const trace = new MutableLLMRequestTrace(); + return { + trace, + result: this.requestWithTrace(trace, overrides, onPart, signal), + }; + } + + private async requestWithTrace( + trace: MutableLLMRequestTrace, + overrides: AgentLLMRequestOverrides, + onPart: AgentLLMRequestPartHandler, + signal: AbortSignal | undefined, + ): Promise { + signal?.throwIfAborted(); + const startedAt = Date.now(); + const setTrace = (traceId: string | undefined): void => { + trace.set(traceId); + if (overrides.source?.type === 'turn') { + this.telemetry.setContext({ trace_id: traceId }); + } + }; + setTrace(undefined); + try { + return await this.runRequest( + this.resolveRequest(overrides), + onPart, + signal, + setTrace, + overrides.onAttemptRetry, + ); + } catch (error) { + this.logRequestFailure(error, overrides, signal); + setTrace(this.trackApiError(error, startedAt, signal, overrides.source, trace.traceId)); + throw error; + } + } + + private logRequestFailure( + error: unknown, + overrides: AgentLLMRequestOverrides, + signal: AbortSignal | undefined, + ): void { + if (isAbortError(error) || signal?.aborted === true) return; + const payload: LogContext = { + ...logFieldsForSource(overrides.source), + model: this.profile.data().modelAlias ?? 'unknown', + ...retryErrorFields(error), + }; + this.log.warn('llm request failed', payload); + } + + private trackApiError( + error: unknown, + startedAt: number, + signal: AbortSignal | undefined, + source?: AgentLLMRequestSource, + requestTraceId?: string, + ): string | undefined { + if (isAbortError(error) || signal?.aborted === true) return requestTraceId; + const modelAlias = this.profile.data().modelAlias; + const model = this.tryGetModel(); + const traceId = requestTraceId ?? apiTraceId(error); + const classification = classifyApiError(unwrapErrorCause(error)); + const properties: ApiErrorEvent = { + error_type: classification.kind, + model: model?.id ?? modelAlias ?? 'unknown', + alias: modelAlias, + provider_type: model?.providerType ?? model?.protocol, + protocol: model?.protocol, + retryable: isRetryableGenerateError(error), + duration_ms: Math.max(0, Date.now() - startedAt), + turn_id: source?.turnId, + request_kind: requestKindForTelemetry(source), + trace_id: traceId, + }; + if (source?.type === 'turn') { + if (source.step !== undefined) properties['step_no'] = source.step; + } + const statusCode = apiStatusCode(error); + if (statusCode !== undefined) properties['status_code'] = statusCode; + const currentTurn = this.usage.status(this.scopeContext.agentContext).currentTurn; + if (currentTurn !== undefined) properties['input_tokens'] = inputTotal(currentTurn); + this.telemetry.track2('api_error', properties); + return traceId; + } + + private tryGetModel(): Model | undefined { + const modelAlias = this.profile.data().modelAlias; + if (modelAlias === undefined) return undefined; + try { + return this.modelCatalog.get(modelAlias); + } catch { + return undefined; + } + } + + private async runRequest( + request: ResolvedLLMRequest, + onPart: AgentLLMRequestPartHandler, + signal: AbortSignal | undefined, + onRequestTrace: (traceId: string | undefined) => void, + onAttemptRetry: (() => void) | undefined, + ): Promise { + this.toolCallIdNormalizer.seedFrom(this.context.get()); + const shaped = this.toolSelect.shapeHistory(request.messages); + const recoveredStrip = this.mediaStripSnapshotForTurn(request.source); + let policy: ProjectionPolicy | undefined = + recoveredStrip !== undefined + ? { media: { strip: recoveredStrip } } + : this.isRecoveryTurn(this.mediaDegradedTurns, request.source) + ? { media: 'degraded' } + : undefined; + const captureMediaStripPolicy = (): { readonly strip: MediaStripSnapshot } => { + const snapshot = this.projector.captureMediaStripSnapshot(shaped); + this.markMediaStrippedRecoveryTurn(snapshot, request.source); + return { strip: snapshot }; + }; + let previousMediaCount: number | undefined; + let previousMediaPolicy: ProjectionPolicy['media']; + let mediaPaths: ReadonlyMap | undefined; + const run = async ( + policy: ProjectionPolicy | undefined, + ): Promise => { + onRequestTrace(undefined); + const projection = projectionNameOf(policy); + const fields = + projection === undefined ? request.logFields : { ...request.logFields, projection }; + if (policy?.media !== undefined) { + mediaPaths ??= await this.mediaResolver.displayPaths(shaped); + } + const projected = this.projector.project(shaped, policy, mediaPaths); + const currentMediaCount = mediaPartCount(projected); + const mediaPolicyChanged = + previousMediaCount !== undefined && previousMediaPolicy !== policy?.media; + const droppedMediaCount = + previousMediaCount === undefined ? 0 : previousMediaCount - currentMediaCount; + previousMediaCount = currentMediaCount; + previousMediaPolicy = policy?.media; + const input = { + systemPrompt: request.systemPrompt, + tools: request.tools, + messages: await this.mediaResolver.resolve(projected, request.requester, signal), + }; + const mediaProjection = + projection === 'media-degraded' || projection === 'strict-media-degraded' + ? 'media-degraded' + : projection === 'media-stripped' || projection === 'strict-media-stripped' + ? 'media-stripped' + : undefined; + if (mediaPolicyChanged && droppedMediaCount > 0 && mediaProjection !== undefined) { + try { + void this.dispatcher.dispatch( + new WarningIssued({ + agentId: this.scopeContext.agentId, + code: mediaProjection, + message: + mediaProjection === 'media-degraded' + ? 'Provider rejected the request as too large; older media were dropped and the request was retried.' + : 'Provider rejected the media in the request; all media were omitted and the request was retried.', + }), + ); + } catch { + } + } + this.warnAboutAnthropicThinkingEffort(request); + const logInput: LLMRequestLogInput = { + protocol: request.model.protocol, + providerType: request.model.providerType, + modelName: request.model.name, + modelAlias: request.modelAlias, + thinkingEffort: request.thinkingEffort, + maxTokens: effectiveMaxCompletionTokens(request.params), + systemPrompt: input.systemPrompt, + tools: input.tools, + messages: input.messages, + fields, + }; + this.logRequest(logInput); + this.recordRequest(logInput); + + let message: Message | undefined; + let usage: TokenUsage | undefined; + let timing: ModelRequestTiming | undefined; + let finish: Extract | undefined; + const toolCallIds = this.toolCallIdNormalizer.beginResponse(); + + const setTraceId = (traceId: string | null | undefined): void => { + const normalized = traceId ?? undefined; + onRequestTrace(normalized); + }; + + try { + for await (const event of request.requester.request(input, signal, { + ...request.params, + onTraceId: setTraceId, + })) { + switch (event.type) { + case 'part': + await onPart(this.normalizeStreamPart(toolCallIds, event.part)); + break; + case 'usage': + usage = event.usage; + break; + case 'finish': + finish = event; + message = event.message; + setTraceId(event.traceId); + break; + case 'timing': { + const { type: _type, ...streamTiming } = event; + timing = streamTiming; + break; + } + } + } + + if (message === undefined || finish === undefined) { + throw new Error2( + ErrorCodes.PROVIDER_API_ERROR, + 'LLM request stream ended without a finish event.', + ); + } + + const finalizedCalls = toolCallIds.remapFinalizedCalls(message.toolCalls); + if (finalizedCalls !== message.toolCalls) { + message = { ...message, toolCalls: finalizedCalls }; + } + for (const { raw, assigned } of toolCallIds.remapped) { + this.log.warn('Rewrote a duplicate provider tool call id into an agent-unique one.', { + raw, + assigned, + model: request.modelAlias, + }); + } + } catch (error) { + toolCallIds.rollback(); + throw error; + } + + void this.usage.record( + this.scopeContext.agentContext, + request.modelAlias, + usage ?? emptyUsage(), + request.source, + ); + if (usage !== undefined) { + this.tokenCounting.measured(this.scopeContext.agentContext, request.messages, [message], usage); + } + this.logResponse(request.logFields, usage ?? emptyUsage(), timing); + + return { + message, + usage: usage ?? emptyUsage(), + model: request.modelAlias, + providerFinishReason: finish.providerFinishReason, + rawFinishReason: finish.rawFinishReason, + providerMessageId: finish.id, + timing, + traceId: finish.traceId, + }; + }; + + let infiniteRetryAttempt = 0; + for (;;) { + try { + return await run(policy); + } catch (error) { + const nextPolicy = this.nextProjectionPolicyForError( + error, + policy, + request, + signal, + captureMediaStripPolicy, + ); + if (nextPolicy !== undefined) { + onAttemptRetry?.(); + policy = nextPolicy; + continue; + } + const raw = unwrapErrorCause(error); + if ( + !this.infiniteRetryEnabled || + isAbortError(error) || + signal?.aborted === true || + raw instanceof APIContextOverflowError + ) { + throw error; + } + infiniteRetryAttempt += 1; + const delayMs = + readRetryAfterMs(raw) ?? + retryBackoffDelay(infiniteRetryAttempt - 1); + this.log.warn('llm request failed; retrying indefinitely (KIMI_CODE_INFINITE_RETRY)', { + model: request.model.name, + ...request.logFields, + attempt: infiniteRetryAttempt, + delayMs, + ...retryErrorFields(error), + }); + onAttemptRetry?.(); + await sleepForRetry(delayMs, signal); + } + } + } + + private get infiniteRetryEnabled(): boolean { + return parseBooleanEnv(this.bootstrap.getEnv(KIMI_CODE_INFINITE_RETRY_ENV)) === true; + } + + private nextProjectionPolicyForError( + error: unknown, + policy: ProjectionPolicy | undefined, + request: ResolvedLLMRequest, + signal: AbortSignal | undefined, + captureMediaStripPolicy: () => { readonly strip: MediaStripSnapshot }, + ): ProjectionPolicy | undefined { + if (signal?.aborted === true) return undefined; + const raw = unwrapErrorCause(error); + const media = policy?.media; + let projection: LlmRequestProjectionFallbackEvent['projection']; + let nextPolicy: ProjectionPolicy; + if ( + raw instanceof APIRequestTooLargeError && + (media === undefined || media === 'degraded') + ) { + signal?.throwIfAborted(); + if (media === undefined) { + this.log.warn('provider rejected request as too large; resending with degraded media', { + model: request.model.name, + ...request.logFields, + }); + this.markRecoveryTurn(this.mediaDegradedTurns, request.source); + projection = 'media-degraded'; + nextPolicy = { ...policy, media: 'degraded' }; + } else { + this.log.warn( + 'provider rejected degraded-media request as too large; resending with rejected media stripped', + { + model: request.model.name, + ...request.logFields, + }, + ); + projection = 'media-stripped'; + nextPolicy = { ...policy, media: captureMediaStripPolicy() }; + } + } else if (typeof media !== 'object' && isImageFormatError(raw)) { + signal?.throwIfAborted(); + this.log.warn( + 'provider rejected an image in the request; resending with rejected media stripped', + { + model: request.model.name, + ...request.logFields, + }, + ); + projection = 'media-stripped'; + nextPolicy = { ...policy, media: captureMediaStripPolicy() }; + } else if (policy?.structure === undefined && isRecoverableRequestStructureError(raw)) { + signal?.throwIfAborted(); + this.log.warn('provider rejected request structure; resending with strict projection', { + model: request.model.name, + ...request.logFields, + }); + projection = 'strict'; + nextPolicy = { ...policy, structure: 'strict' }; + } else { + return undefined; + } + const properties: LlmRequestProjectionFallbackEvent = { + projection, + error_type: classifyApiError(raw).kind, + model: request.model.id, + turn_id: request.source?.turnId, + }; + this.telemetry.track2('llm_request_projection_fallback', properties); + return nextPolicy; + } + + private normalizeStreamPart( + toolCallIds: ToolCallIdResponseNormalizer, + part: StreamedMessagePart, + ): StreamedMessagePart { + if (!isToolCall(part)) return part; + const assigned = toolCallIds.remapStreamedId(part.id, part._streamIndex); + return assigned === part.id ? part : { ...part, id: assigned, rawId: part.rawId ?? part.id }; + } + + private warnAboutAnthropicThinkingEffort(request: ResolvedLLMRequest): void { + if (request.model.protocol !== 'anthropic') return; + const effort = request.thinkingEffort; + if (effort === 'on' || effort === 'off') return; + + let code: string; + let message: string; + let knownEfforts: string | undefined; + const supportEfforts = request.model.supportEfforts?.filter((value) => value.length > 0); + if (supportEfforts === undefined || supportEfforts.length === 0) return; + if (supportEfforts.includes(effort)) return; + code = 'anthropic-thinking-effort-not-listed'; + knownEfforts = supportEfforts.join(','); + message = `Thinking effort "${effort}" is not listed for model "${request.model.name}" (known: ${supportEfforts.join(', ')}). The configured value will be sent unchanged to the Anthropic-compatible backend.`; + + const key = [code, request.modelAlias, request.model.name, effort, knownEfforts].join('\u0000'); + if (this.emittedThinkingEffortWarnings.has(key)) return; + this.emittedThinkingEffortWarnings.add(key); + try { + this.log.warn(message, { + modelAlias: request.modelAlias, + model: request.model.name, + effort, + knownEfforts, + }); + } catch { + } + try { + void this.dispatcher.dispatch( + new WarningIssued({ agentId: this.scopeContext.agentId, code, message }), + ); + } catch { + } + } + + private isRecoveryTurn(set: ReadonlySet, source: AgentLLMRequestSource | undefined): boolean { + if (source?.type !== 'turn') return false; + return set.has(source.turnId); + } + + private mediaStripSnapshotForTurn( + source: AgentLLMRequestSource | undefined, + ): MediaStripSnapshot | undefined { + if (source?.type !== 'turn') return undefined; + return this.mediaStrippedTurns.get(source.turnId); + } + + private markMediaStrippedRecoveryTurn( + snapshot: MediaStripSnapshot, + source: AgentLLMRequestSource | undefined, + ): void { + if (source?.type !== 'turn') return; + for (const id of this.mediaStrippedTurns.keys()) { + if (id < source.turnId) this.mediaStrippedTurns.delete(id); + } + this.mediaStrippedTurns.set(source.turnId, snapshot); + } + + private markRecoveryTurn(set: Set, source: AgentLLMRequestSource | undefined): void { + if (source?.type !== 'turn') return; + for (const id of set) { + if (id < source.turnId) set.delete(id); + } + set.add(source.turnId); + } + + private resolveRequest(overrides: AgentLLMRequestOverrides): ResolvedLLMRequest { + const turnConfig = this.resolveTurnConfig(overrides.source); + const resolved = turnConfig?.resolved ?? this.profile.resolveModelContext(); + const baseParams = turnConfig?.params ?? this.profile.resolveRequestParams(); + const budgetParams = completionBudgetParams({ + budget: resolveCompletionBudget({ + maxOutputSize: overrides.maxOutputSize ?? resolved.maxOutputSize, + reservedContextSize: resolved.reservedContextSize, + maxCompletionTokensCap: + this.config.get('modelOverrides')?.maxCompletionTokens, + }), + capability: resolved.modelCapabilities, + usedContextTokens: + overrides.messages === undefined + ? this.tokenCounting.get(this.scopeContext.agentContext).measured + : undefined, + }); + const requester = this.modelCatalog.getRequester(resolved.modelAlias); + + const messages = overrides.messages ?? this.context.get(); + return { + requester, + model: requester.model, + params: { ...baseParams, ...budgetParams }, + modelAlias: resolved.modelAlias, + thinkingEffort: resolved.thinkingLevel, + systemPrompt: overrides.systemPrompt ?? turnConfig?.systemPrompt ?? this.profile.getSystemPrompt(), + tools: [...(overrides.tools ?? this.defaultTools())], + messages: [...messages], + source: overrides.source, + logFields: logFieldsForSource(overrides.source), + }; + } + + private resolveTurnConfig(source: AgentLLMRequestSource | undefined): TurnRequestConfig | undefined { + if (source?.type !== 'turn') return undefined; + return this.getOrCreateTurnConfig(source.turnId); + } + + private getOrCreateTurnConfig(turnId: number): TurnRequestConfig { + for (const id of this.turnConfigs.keys()) { + if (id < turnId) this.turnConfigs.delete(id); + } + let snapshot = this.turnConfigs.get(turnId); + if (snapshot === undefined) { + snapshot = { + resolved: this.profile.resolveModelContext(), + params: this.profile.resolveRequestParams(), + systemPrompt: this.profile.getSystemPrompt(), + }; + this.turnConfigs.set(turnId, snapshot); + } + return snapshot; + } + + private logRequest(input: LLMRequestLogInput): void { + const logFields: AgentLLMRequestLogFields = input.fields ?? {}; + const wireTools = providerVisibleTools(input.tools); + const config = { + provider: input.protocol, + model: input.modelName, + modelAlias: input.modelAlias, + thinkingEffort: input.thinkingEffort ?? undefined, + systemPromptChars: input.systemPrompt.length, + toolCount: wireTools.length, + }; + const signature = JSON.stringify({ + ...config, + systemPromptHash: fingerprint(input.systemPrompt), + toolsHash: fingerprint(JSON.stringify(toolSignature(wireTools))), + }); + if (signature !== this.lastConfigLogSignature) { + this.lastConfigLogSignature = signature; + this.log.info('llm config', { ...logFields, ...config }); + } + + const partialMessageCount = input.messages.filter((message) => message.partial === true).length; + const requestFields: LogContext = { ...logFields }; + if (partialMessageCount > 0) requestFields['partialMessageCount'] = partialMessageCount; + this.log.info('llm request', requestFields); + } + + private recordRequest(input: LLMRequestLogInput): void { + const fields = input.fields ?? {}; + const wireTools = providerVisibleTools(input.tools); + const tools = toolSignature(wireTools); + const toolsHash = fingerprint(JSON.stringify(tools)); + if (!this.states.get(llmRequestTraceKey).seenToolsHashes.includes(toolsHash)) { + void this.dispatcher.dispatch( + new LlmToolsSnapshot({ agentId: this.scopeContext.agentId, hash: toolsHash, tools }), + ); + } + + const systemPromptHash = fingerprint(input.systemPrompt); + const overrides = this.config.get('modelOverrides'); + const thinkingConfig = this.config.get(THINKING_SECTION); + const modelConfig = + input.modelAlias === undefined ? undefined : this.modelService.get(input.modelAlias); + const payload: LlmRequestPayload = { + agentId: this.scopeContext.agentId, + kind: requestKindForRecord(fields), + provider: input.protocol, + model: input.modelName, + modelAlias: input.modelAlias, + thinkingEffort: input.thinkingEffort ?? undefined, + thinkingKeep: resolveThinkingKeep( + overrides?.thinkingKeep, + thinkingConfig?.keep, + input.thinkingEffort ?? 'off', + ), + temperature: overrides?.temperature, + topP: overrides?.topP, + maxTokens: input.maxTokens, + betaApi: modelConfig?.betaApi, + toolSelect: this.toolSelect.enabled(), + systemPromptHash, + systemPrompt: + input.systemPrompt === this.profile.data().systemPrompt + ? undefined + : input.systemPrompt, + toolsHash, + messageCount: input.messages.length, + turnStep: stringField(fields, 'turnStep'), + attempt: stringField(fields, 'attempt'), + projection: projectionField(fields), + droppedCount: numberField(fields, 'droppedCount'), + }; + void this.dispatcher.dispatch(new LlmRequest(payload)); + } + + private logResponse( + fields: AgentLLMRequestLogFields | undefined, + usage: TokenUsage, + timing: ModelRequestTiming | undefined, + ): void { + if (timing === undefined) return; + const payload: LogContext = { + ...fields, + ttftMs: timing.firstTokenLatencyMs, + streamDurationMs: timing.streamDurationMs, + outputTokens: usage.output, + }; + if (timing.requestBuildMs !== undefined) payload['requestBuildMs'] = timing.requestBuildMs; + if (timing.serverFirstTokenMs !== undefined) { + payload['serverFirstTokenMs'] = timing.serverFirstTokenMs; + } + if (timing.serverDecodeMs !== undefined) payload['serverDecodeMs'] = timing.serverDecodeMs; + if (timing.clientConsumeMs !== undefined) payload['clientConsumeMs'] = timing.clientConsumeMs; + if (timing.clientBlockedMs !== undefined) payload['clientBlockedMs'] = timing.clientBlockedMs; + this.log.info('llm response', payload); + } + + private defaultTools(): readonly Tool[] { + return this.toolSelect + .shapeTools(this.tools.list()) + .map((tool) => ({ + name: tool.name, + description: tool.description, + parameters: tool.parameters ?? EMPTY_TOOL_PARAMETERS, + deferred: tool.deferred, + })); + } +} + +class MutableLLMRequestTrace implements LLMRequestTrace { + traceId: string | undefined; + + set(traceId: string | undefined): void { + this.traceId = traceId; + } +} + +function logFieldsForSource(source: AgentLLMRequestSource | undefined): AgentLLMRequestLogFields { + switch (source?.type) { + case 'turn': + return { + ...source.logFields, + ...(source.step === undefined + ? {} + : { turnStep: `${String(source.turnId)}.${String(source.step)}` }), + }; + case 'operation': + return { + ...source.logFields, + ...(source.requestKind === undefined ? {} : { requestKind: source.requestKind }), + }; + default: + return {}; + } +} + +function requestKindForTelemetry(source: AgentLLMRequestSource | undefined): string | undefined { + if (source?.type === 'turn') return 'turn'; + if (source?.type === 'operation') return source.requestKind ?? 'operation'; + return undefined; +} + +function providerVisibleTools(tools: readonly Tool[]): readonly Tool[] { + if (!tools.some((tool) => tool.deferred === true)) return tools; + return tools.filter((tool) => tool.deferred !== true); +} + +function toolSignature(tools: readonly Tool[]): readonly LlmRequestToolSchema[] { + return tools.map(({ name, description, parameters }) => ({ name, description, parameters })); +} + +function requestKindForRecord(fields: AgentLLMRequestLogFields): LlmRequestPayload['kind'] { + if (fields['kind'] === 'compaction') return 'compaction'; + if (fields['requestKind'] === 'full_compaction') return 'compaction'; + return 'loop'; +} + +function stringField(fields: AgentLLMRequestLogFields, key: string): string | undefined { + const value = fields[key]; + return typeof value === 'string' ? value : undefined; +} + +function numberField(fields: AgentLLMRequestLogFields, key: string): number | undefined { + const value = fields[key]; + return typeof value === 'number' ? value : undefined; +} + +function mediaPartCount(messages: readonly Message[]): number { + return messages.reduce( + (count, message) => + count + + message.content.filter( + (part) => + part.type === 'image_url' || part.type === 'audio_url' || part.type === 'video_url', + ).length, + 0, + ); +} + +type LlmRequestProjection = NonNullable; + +function projectionNameOf(policy: ProjectionPolicy | undefined): LlmRequestProjection | undefined { + if (policy?.structure === 'strict') { + if (policy.media === 'degraded') return 'strict-media-degraded'; + if (typeof policy.media === 'object') return 'strict-media-stripped'; + return 'strict'; + } + if (policy === undefined) return undefined; + if (policy.media === 'degraded') return 'media-degraded'; + if (typeof policy.media === 'object') return 'media-stripped'; + return undefined; +} + +function projectionField(fields: AgentLLMRequestLogFields): LlmRequestProjection | undefined { + const value = fields['projection']; + switch (value) { + case 'strict': + case 'media-degraded': + case 'media-stripped': + case 'strict-media-degraded': + case 'strict-media-stripped': + return value; + default: + return undefined; + } +} + +function fingerprint(content: string): string { + return createHash('sha256').update(content).digest('hex'); +} + +function apiStatusCode(error: unknown): number | undefined { + const raw = unwrapErrorCause(error); + if (raw instanceof APIStatusError) return raw.statusCode; + if (typeof raw === 'object' && raw !== null) { + const statusCode = (raw as Record)['statusCode']; + if (typeof statusCode === 'number') return statusCode; + const status = (raw as Record)['status']; + if (typeof status === 'number') return status; + } + if (typeof error === 'object' && error !== null) { + const details = (error as Record)['details']; + if (typeof details === 'object' && details !== null) { + const statusCode = (details as Record)['statusCode']; + if (typeof statusCode === 'number') return statusCode; + } + } + return undefined; +} + +function apiTraceId(error: unknown): string | undefined { + const raw = unwrapErrorCause(error); + if (raw instanceof APIStatusError && raw.traceId !== null) return raw.traceId; + if (typeof error === 'object' && error !== null) { + const details = (error as Record)['details']; + if (typeof details === 'object' && details !== null) { + const traceId = (details as Record)['traceId']; + if (typeof traceId === 'string') return traceId; + } + } + return undefined; +} + +registerScopedService( + LifecycleScope.Agent, + IAgentLLMRequesterService, + AgentLLMRequesterService, + ScopeActivation.OnScopeCreated, + 'llmRequester', +); diff --git a/packages/agent-core-v2/src/agent/loop/configSection.ts b/packages/agent-core-v2/src/agent/loop/configSection.ts new file mode 100644 index 0000000000000000000000000000000000000000..17c306d7ee43707c2023ec30c3e5f896a72b9707 --- /dev/null +++ b/packages/agent-core-v2/src/agent/loop/configSection.ts @@ -0,0 +1,55 @@ +import { z } from 'zod'; + +import { type EnvBindings, envBindings, stripEnvBoundFields } from '#/app/config/config'; +import { registerConfigSection } from '#/app/config/configSectionContributions'; +import { plainObjectToToml } from '#/app/config/toml'; + +export const LOOP_CONTROL_SECTION = 'loopControl'; + +export const LOOP_MAX_STEPS_PER_TURN_ENV = 'KIMI_LOOP_MAX_STEPS_PER_TURN'; +export const LOOP_MAX_ATTEMPTS_PER_STEP_ENV = 'KIMI_LOOP_MAX_ATTEMPTS_PER_STEP'; +export const LOOP_MAX_RETRIES_PER_STEP_ENV = 'KIMI_LOOP_MAX_RETRIES_PER_STEP'; + +export const LoopControlSchema = z.object({ + maxStepsPerTurn: z.number().int().min(0).optional(), + maxAttemptsPerStep: z.number().int().min(0).optional(), + maxRalphIterations: z.number().int().min(-1).optional(), + reservedContextSize: z.number().int().min(0).optional(), + compactionTriggerRatio: z.number().min(0.5).max(0.99).optional(), + compactionMaxAttempts: z.number().int().min(1).optional(), +}); + +export type LoopControl = z.infer; + +function parseNonNegativeInt(raw: string): number | undefined { + const value = raw.trim(); + if (value.length === 0 || !/^\d+$/.test(value)) return undefined; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined; +} + +export const loopControlEnvBindings: EnvBindings = envBindings(LoopControlSchema, { + maxStepsPerTurn: { env: LOOP_MAX_STEPS_PER_TURN_ENV, parse: parseNonNegativeInt }, + maxAttemptsPerStep: { + env: LOOP_MAX_ATTEMPTS_PER_STEP_ENV, + deprecatedEnv: LOOP_MAX_RETRIES_PER_STEP_ENV, + parse: parseNonNegativeInt, + }, +}); + +export const stripLoopControlEnv = stripEnvBoundFields(loopControlEnvBindings); + +export const loopControlToToml = (value: unknown, rawSnake: unknown): unknown => { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return value; + return plainObjectToToml(value as Record, rawSnake); +}; + +registerConfigSection(LOOP_CONTROL_SECTION, LoopControlSchema, { + toToml: loopControlToToml, + env: loopControlEnvBindings, + stripEnv: stripLoopControlEnv, + deprecations: [ + { key: 'max_retries_per_step', replacement: 'max_attempts_per_step' }, + { key: 'max_steps_per_run', replacement: 'max_steps_per_turn' }, + ], +}); diff --git a/packages/agent-core-v2/src/agent/loop/errors.ts b/packages/agent-core-v2/src/agent/loop/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..662b4ee5fc01e79e4a58d7f13f8b95b5dd7592e7 --- /dev/null +++ b/packages/agent-core-v2/src/agent/loop/errors.ts @@ -0,0 +1,20 @@ +import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; + +export const LoopErrors = { + codes: { + LOOP_MAX_STEPS_EXCEEDED: 'loop.max_steps_exceeded', + TURN_AGENT_BUSY: 'turn.agent_busy', + }, + retryable: ['turn.agent_busy'], + info: { + 'loop.max_steps_exceeded': { + title: 'Loop max steps exceeded', + retryable: false, + public: true, + action: + 'Raise loop_control.max_steps_per_turn in config.toml, or run "/update-config" then "/reload".', + }, + }, +} as const satisfies ErrorDomain; + +registerErrorDomain(LoopErrors); diff --git a/packages/agent-core-v2/src/agent/loop/loop.ts b/packages/agent-core-v2/src/agent/loop/loop.ts new file mode 100644 index 0000000000000000000000000000000000000000..b82190b040ea3deaa2dbe1a80d756c75a36cc1c1 --- /dev/null +++ b/packages/agent-core-v2/src/agent/loop/loop.ts @@ -0,0 +1,241 @@ +import { createDecorator } from '#/_base/di/instantiation'; +import type { IDisposable } from '#/_base/di/lifecycle'; +import { Error2, isError2, type Error2Options } from '#/_base/errors/errors'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import type { FinishReason } from '#human/llm/finish-reason'; +import type { ContentPart } from '#human/llm/message'; +import type { TokenUsage } from '#human/llm/usage'; +import type { Hooks } from '#/hooks'; +import type { UserEntry } from '#human/agent/turn'; +import { LoopErrors } from './errors'; +import type { + MachineEngine, + MachineEngineAttachBundle, + MachineEngineAttachRef, + MachineEngineRetrySnapshot, + MachineEngineToolCallSnapshot, +} from './machine/engine'; + +export interface AgentActivityTurnSnapshot { + readonly turnId: number; + readonly phase: 'running' | 'tool_call' | 'retrying'; + readonly step: number; + readonly ending: boolean; + readonly endingReason?: 'aborted'; + readonly retry?: MachineEngineRetrySnapshot; + readonly activeToolCalls: readonly MachineEngineToolCallSnapshot[]; + readonly since?: number; +} + +export interface AgentActivitySnapshot { + readonly turn?: AgentActivityTurnSnapshot; +} + +export interface LoopSnapshot { + readonly state: 'idle' | 'running'; + readonly activeTurnId?: number; + readonly activePromptId?: string; + readonly queue: readonly UserEntry[]; + readonly notificationCount: number; + readonly paused: boolean; + readonly hasPendingRequests: boolean; + readonly turn?: AgentActivityTurnSnapshot; + readonly activeTraceId?: string; +} + +export type LoopErrorCode = (typeof LoopErrors.codes)[keyof typeof LoopErrors.codes]; + +export class LoopError extends Error2 { + constructor(code: LoopErrorCode, message: string, options?: Error2Options) { + super(code, message, options); + this.name = 'LoopError'; + } +} + +export function createMaxStepsExceededError(maxSteps: number, message?: string): LoopError { + return new LoopError( + LoopErrors.codes.LOOP_MAX_STEPS_EXCEEDED, + message ?? + `Turn exceeded maxSteps=${maxSteps}. If max_steps_per_turn is too small, raise it in config.toml (loop_control.max_steps_per_turn), or run "/update-config" to update it, then "/reload".`, + { details: { maxSteps } }, + ); +} + +export function isMaxStepsExceededError(error: unknown): boolean { + return isError2(error) && error.code === LoopErrors.codes.LOOP_MAX_STEPS_EXCEEDED; +} + +export interface BeforeStepContext { + readonly turnId: number; + readonly step: number; + readonly firstStepOfTurn: boolean; + readonly signal: AbortSignal; +} + +export interface AfterStepContext extends BeforeStepContext { + readonly usage: TokenUsage; + readonly finishReason: FinishReason; + stopTurn: boolean; +} + +export interface LoopErrorContext { + readonly turnId: number; + readonly step?: number; + readonly stepId?: string; + readonly signal: AbortSignal; + readonly error: unknown; + retry(): void; +} + +export interface LoopErrorHandler { + readonly id: string; + match(context: LoopErrorContext): boolean; + handle(context: LoopErrorContext): Promise; +} + +export interface LoopErrorHandlerRegistrationOptions { + readonly before?: string; + readonly after?: string; +} + +export type LoopRunResult = + | { + readonly type: 'completed'; + readonly steps: number; + readonly truncated: boolean; + readonly stopReason?: string; + } + | { + readonly type: 'failed'; + readonly steps: number; + readonly error: unknown; + } + | { + readonly type: 'cancelled'; + readonly steps: number; + readonly reason: unknown; + }; + +export type TurnResult = LoopRunResult; + +export interface Turn { + readonly id?: number; + readonly state?: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'; + readonly signal: AbortSignal; + readonly ready: Promise; + readonly result: Promise; + cancel(reason?: unknown): boolean; +} + +export interface LoopSubmitOptions { + readonly steerIfActive?: boolean; + readonly onMaterialize?: () => void; +} + +export interface LoopSubmitResult { + readonly id: string; +} + +export interface LoopCancelTarget { + readonly turnId?: number; + readonly promptId?: string; +} + +export type PromptState = + | 'pending' + | 'running' + | 'steered' + | 'completed' + | 'failed' + | 'cancelled' + | 'blocked'; + +export interface PromptCompletion { + readonly promptId: string; + readonly result: TurnResult | undefined; + readonly state: Extract; +} + +export interface PromptSnapshot { + readonly id: string; + readonly userMessageId: string; + readonly createdAt: string; + readonly state: PromptState; + readonly message: ContextMessage; +} + +export interface PromptHandle extends PromptSnapshot { + readonly launched: Promise; + readonly completion: Promise; +} + +export interface PromptPayload { + readonly input: readonly ContentPart[]; + readonly promptId?: string; +} + +export interface SteerPayload { + readonly input: readonly ContentPart[]; +} + +export interface PromptLaunchResult { + readonly turn_id: number; +} + +export interface PromptSubmitContext { + readonly promptMessage: ContextMessage; + readonly isSteer: boolean; + block: boolean; +} + +export interface LoopNotify { + readonly message?: ContextMessage; + readonly turnScoped?: boolean; + readonly bypassMaxSteps?: boolean; + readonly onConsume?: () => void; + readonly onDrop?: () => void; +} + +export interface LoopNotifyHandle { + readonly dropped: boolean; + drop(): void; +} + +export interface IAgentLoopService { + readonly _serviceBrand: undefined; + + submit(input: UserEntry, options?: LoopSubmitOptions): LoopSubmitResult; + + steer(promptIds: readonly string[]): Promise; + + cancel(target?: LoopCancelTarget, reason?: unknown): boolean; + + snapshot(): LoopSnapshot; + + settled(): Promise; + + tryAcquireQuiescence(): IDisposable | undefined; + + notify(note?: LoopNotify): LoopNotifyHandle; + + buildAttachBundle(): MachineEngineAttachBundle; + + attachEngine(ref: MachineEngineAttachRef, bundle: MachineEngineAttachBundle): MachineEngine; + + resetMachineEngine(): Promise; + + promptHandle(id: string): PromptHandle | undefined; + + registerLoopErrorHandler( + handler: LoopErrorHandler, + options?: LoopErrorHandlerRegistrationOptions, + ): IDisposable; + + readonly hooks: Hooks<{ + onWillBeginStep: BeforeStepContext; + onDidFinishStep: AfterStepContext; + onBeforeSubmitPrompt: PromptSubmitContext; + }>; +} + +export const IAgentLoopService = createDecorator('agentLoopService'); diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts new file mode 100644 index 0000000000000000000000000000000000000000..4fcbf7ca505554c702a725aa1e75272cd9d6b1ab --- /dev/null +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -0,0 +1,2262 @@ +import { randomUUID } from 'node:crypto'; +import { EventEmitter } from 'node:events'; + +import { createControlledPromise } from '@antfu/utils'; + +import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; +import { IInstantiationService } from '#/_base/di/instantiation'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { defineState } from '#/state/state'; +import { abortError, isAbortError, isUserCancellation, userCancellationReason } from '#/_base/utils/abort'; +import { toErrorMessage } from '#/_base/errors/errorMessage'; +import { onUnexpectedError } from '#/_base/errors/unexpectedError'; +import { retryErrorFields } from '#/_base/utils/retry'; +import { IAgentLLMRequesterService } from '#/agent/llmRequester/llmRequester'; +import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; +import type { ModelRequestTiming } from '#/llm-adapter/model/model-requester'; +import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; +import { abortedToolOutput } from '#/agent/toolExecutor/toolExecutorService'; +import type { ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; +import type { ExecutableToolResult } from '#/tool/toolContract'; +import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; +import { IConfigService } from '#/app/config/config'; +import { AgentErrorEvent } from '#/agent/mcp/mcpEvents'; +import { type FinishReason } from '#human/llm/finish-reason'; +import { mergeInPlace } from '#/llm-adapter/contract/message'; +import type { ContentPart, UserMessage } from '#human/llm/message'; +import { emptyUsage, type TokenUsage } from '#human/llm/usage'; +import { BugIndicatingError, ErrorCodes, Error2, isError2, toKimiErrorPayload } from '#/errors'; +import { OrderedHookSlot } from '#/hooks'; + +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent'; +import { newMessageId } from '#/agent/contextMemory/messageId'; +import { type ContextMessage, type PromptOrigin } from '#/agent/contextMemory/types'; +import { gateImageFormatParts } from '#/agent/media/image-compress'; +import { daemonFileRefFromPart } from '#/agent/media/mediaRef'; +import { materializePromptDaemonRefs } from '#/agent/media/promptMediaIntake'; +import { ISessionMediaStore } from '#/agent/media/sessionMediaStore'; +import { IAgentProfileService } from '#/agent/profile/profile'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { IFileService } from '#/app/file/fileService'; +import type { + TurnEndedEvent as TurnEndedTelemetryEvent, + TurnInterruptedEvent, + TurnStartedEvent as TurnStartedTelemetryEvent, +} from '#/app/telemetry/events'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { IEventDispatcher } from '#/state/eventDispatcher'; +import { IWireService } from '#/wire/wire'; +import { + PromptAborted, + PromptCompleted, + PromptQueued, + PromptStarted, + PromptSteered, + PromptSubmitted, +} from '#/agent/prompt/promptEvents'; +import { LOOP_CONTROL_SECTION, type LoopControl } from './configSection'; +import { + createMaxStepsExceededError, + IAgentLoopService, + isMaxStepsExceededError, + type AfterStepContext, + type LoopCancelTarget, + type LoopError, + type LoopErrorContext, + type LoopErrorHandler, + type LoopErrorHandlerRegistrationOptions, + type LoopNotify, + type LoopNotifyHandle, + type LoopRunResult, + type LoopSnapshot, + type LoopSubmitOptions, + type LoopSubmitResult, + type PromptCompletion, + type PromptHandle, + type PromptState, + type PromptSubmitContext, + type Turn, + type TurnResult, +} from './loop'; +import { mergeSteerMessages, stripBundledSkillBlocks } from '#human/agent/origin'; +import { createUserEntry, type UserEntry } from '#human/agent/turn'; +import { + AssistantDelta, + isDisplayablePromptOrigin, + ThinkingDelta, + ToolCallDelta, + turnPromptAttachments, + turnPromptText, + TurnStarted, + TurnStepCompleted, + TurnStepInterrupted, + TurnStepRetrying, + TurnStepStarted, + type TurnInterruptReason, +} from './turnEvents'; +import { TurnCancel, TurnEnded, turnKey, TurnPrompt, TurnSteer } from './turnOps'; +import { + attachMachineEngine, + EMPTY_MACHINE_PROMPT, + ENGINE_JOURNAL_DOMAIN, + engineJournal, + historyFromContext, + MACHINE_LOOP_MODEL, + machineEngineAttachBundle, + wireStoreJournal, + type CreateMachineEngineOptions, + type MachineEngine, + type MachineEngineAttachBundle, + type MachineEngineAttachRef, + type MachineEngineEvent, + type MachineTurnOutcome, + type PromptGateVerdict, +} from './machine'; + +export type LoopInterruptReason = 'aborted' | 'max_steps' | 'error'; + +export const loopLastRequestTraceIdKey = defineState( + 'loop.lastRequestTraceId', + () => undefined as string | undefined, +); +export const loopDisposingKey = defineState('loop.disposing', () => false); + +const MAX_STEP_SIGNAL_LISTENERS = 64; + +export class AgentLoopService extends Disposable implements IAgentLoopService { + declare readonly _serviceBrand: undefined; + + readonly hooks: IAgentLoopService['hooks'] = { + onWillBeginStep: new OrderedHookSlot(), + onDidFinishStep: new OrderedHookSlot(), + onBeforeSubmitPrompt: new OrderedHookSlot(), + }; + + private readonly errorHandlers: LoopErrorHandler[] = []; + private readonly promptWaiters = new Map(); + private readonly steered = new Map(); + private readonly terminalStates = new Map(); + private readonly pendingSubmissions: UserEntry[] = []; + private readonly nudges: Nudge[] = []; + private nudgeCursor = 0; + private active: ActiveTurn | undefined; + private pendingMachineTurn: + | { readonly id: number; readonly queueItemId?: string; readonly entry?: UserEntry } + | undefined; + private machineTurnSuppressed = false; + private readonly settleWaiters: Array<() => void> = []; + private quiescenceDepth = 0; + private activeRequestTrace: LLMRequestTrace | undefined; + private engine: MachineEngine | undefined; + + constructor( + @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + @IAgentLLMRequesterService private readonly llmRequester: IAgentLLMRequesterService, + @IAgentToolExecutorService private readonly toolExecutor: IAgentToolExecutorService, + @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, + @IConfigService private readonly config: IConfigService, + @IEventDispatcher private readonly dispatcher: IEventDispatcher, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @ITelemetryService private readonly telemetry: ITelemetryService, + @IAgentStateService private readonly states: IAgentStateService, + @IWireService private readonly wire: IWireService, + @IInstantiationService private readonly instantiation: IInstantiationService, + @IAgentProfileService private readonly profile: IAgentProfileService, + ) { + super(); + this.states.contributeState(turnKey); + this.states.contributeState(loopLastRequestTraceIdKey); + this.states.contributeState(loopDisposingKey); + this.toolExecutor.hooks.onDidExecuteTool.register('prompt-service-delivery', async (ctx, next) => { + await this.deliverToolResult(ctx); + await next(); + }); + } + + 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); + } + + private engineOptions(): CreateMachineEngineOptions { + return { + model: MACHINE_LOOP_MODEL, + llmRequester: this.llmRequester, + toolExecutor: this.toolExecutor, + toolInfos: () => this.toolRegistry.list(), + maxAttemptsPerStep: this.config.get(LOOP_CONTROL_SECTION)?.maxAttemptsPerStep, + initialTurnId: this.states.get(turnKey).nextTurnId, + journal: wireStoreJournal(this.wire, ENGINE_JOURNAL_DOMAIN), + trace: () => this.activeRequestTrace, + toolTurnId: () => this.active?.id, + steerSignal: () => this.active?.steerController.signal, + source: () => + this.active === undefined + ? undefined + : { + type: 'turn', + turnId: this.active.id, + step: this.active.gatedSteps, + }, + gate: (signal) => this.gate(signal), + promptGate: (queueItemId, message) => this.runPromptGate(queueItemId, message), + onTrace: (trace) => { + this.activeRequestTrace = trace; + }, + onEvent: (event) => this.projectMachineEvent(event), + onToolResult: (toolCallId, result) => this.appendMachineToolResult(toolCallId, result), + }; + } + + buildAttachBundle(): MachineEngineAttachBundle { + return machineEngineAttachBundle(this.engineOptions()); + } + + attachEngine(ref: MachineEngineAttachRef, bundle: MachineEngineAttachBundle): MachineEngine { + if (this.engine !== undefined) { + throw new BugIndicatingError('Machine engine already attached'); + } + this.engine = attachMachineEngine(ref, bundle, this.engineOptions()); + if (this.dispatcher.restorePhase === 'new') { + const hook = this.dispatcher.hooks.onDidRestore.register('loop.engineRefold', async (_ctx, next) => { + hook.dispose(); + try { + if (!this.disposing && this.active === undefined && this.pendingMachineTurn === undefined) { + await this.machineEngine().resetJournal(this.freshEngineJournal()); + } + } catch (error) { + onUnexpectedError(error); + } + await next(); + }); + } + this.rebuildRestoredRecords(); + if (this.quiescenceDepth > 0) { + this.machineEngine().pause(); + } + if (!this.disposing) { + this.drainPendingToMachine(); + this.maybeSettle(); + } + return this.engine; + } + + private rebuildRestoredRecords(): void { + if (this.engine === undefined) return; + for (const item of this.engine.snapshot().queue) { + const promptId = item.meta?.promptId; + if (promptId === undefined || this.promptWaiters.has(promptId)) continue; + this.terminalStates.delete(promptId); + this.promptWaiters.set(promptId, this.createWaiter(promptId)); + } + } + + private machineEngine(): MachineEngine { + if (this.engine === undefined) { + throw new BugIndicatingError('Machine engine not attached'); + } + return this.engine; + } + + override dispose(): void { + if (this.disposing) return; + this.disposing = true; + const reason = abortError('Agent loop disposed'); + for (const waiter of this.promptWaiters.values()) { + this.settleWaiterCancelled(waiter); + this.terminalStates.set(waiter.id, 'cancelled'); + } + this.promptWaiters.clear(); + this.steered.clear(); + this.pendingSubmissions.length = 0; + const active = this.active; + active?.turn.cancel(reason); + this.engine?.stop(); + if (active !== undefined) { + this.interruptMachineRunForCancel(active, reason); + void this.endTurn(active, { type: 'cancelled', steps: active.steps, reason }); + } + this.maybeSettle(); + super.dispose(); + } + + submit(input: UserEntry, options?: LoopSubmitOptions): LoopSubmitResult { + if (this.disposing) throw abortError('Agent loop disposed'); + const meta = input.meta; + const id = meta?.promptId ?? newMessageId(); + const origin = (meta?.origin as PromptOrigin | undefined) ?? { kind: 'user' }; + const tracked = meta?.tracked === true; + const createdAt = meta?.createdAt ?? (tracked ? new Date().toISOString() : ''); + const userMessageId = meta?.userMessageId ?? (tracked ? id : ''); + const waiter = this.createWaiter(id, meta?.promptId, options?.onMaterialize); + this.terminalStates.delete(id); + this.promptWaiters.set(id, waiter); + const message: ContextMessage = { + role: 'user', + content: [...input.message.content], + id, + toolCalls: [], + origin: meta?.origin as PromptOrigin | undefined, + }; + if (tracked) { + const queued = + this.active !== undefined || + this.machinePaused() || + (this.engine !== undefined && this.engine.snapshot().queue.length > 0); + this.publishPromptSubmitted( + { promptId: id, origin, userMessageId, createdAt, message }, + queued ? 'queued' : 'running', + ); + if (queued) this.publishPromptQueued({ promptId: id, origin, message }); + } + const entry: UserEntry = { + message: { role: 'user', content: [...input.message.content] }, + meta: { promptId: id, origin, tracked, createdAt, userMessageId }, + }; + if (this.engine !== undefined) { + try { + this.machineEngine().submit(entry); + } catch { + waiter.launched.resolve(undefined); + waiter.completion.resolve({ + promptId: id, + result: undefined, + state: 'failed', + }); + this.publishPromptCompleted(id, 'failed'); + this.terminalStates.set(id, 'failed'); + waiter.failedEntry = entry; + return { id }; + } + } else { + this.pendingSubmissions.push(entry); + } + if ( + options?.steerIfActive === true && + this.active !== undefined && + this.active.prompt.tracked && + this.engine !== undefined + ) { + this.machineEngine().steer(id); + } + return { id }; + } + + async steer(promptIds: readonly string[]): Promise { + if (this.disposing) throw abortError('Agent loop disposed'); + if (promptIds.length === 0) { + throw new Error2(ErrorCodes.REQUEST_INVALID, 'prompt_ids must not be empty'); + } + const active = this.active; + if (active === undefined || !active.prompt.tracked) { + throw new Error2(ErrorCodes.PROMPT_NOT_FOUND, 'no active prompt to steer into'); + } + const engine = this.machineEngine(); + const ids = new Set(promptIds); + const queuedIds = new Set(engine.snapshot().queue.map((item) => item.meta?.promptId)); + if (ids.size !== promptIds.length || ![...ids].every((id) => queuedIds.has(id))) { + throw new Error2(ErrorCodes.PROMPT_NOT_FOUND, 'one or more prompts are not pending'); + } + for (const id of ids) { + const entry = engine.snapshot().queue.find((item) => item.meta?.promptId === id); + if (entry !== undefined) await this.materializeDaemonRefs(entry.message); + } + if ( + this.active !== active || + ![...ids].every((id) => new Set(engine.snapshot().queue.map((item) => item.meta?.promptId)).has(id)) + ) { + throw new Error2(ErrorCodes.PROMPT_NOT_FOUND, 'one or more prompts are no longer pending'); + } + engine.steer(promptIds); + } + + promptHandle(id: string): PromptHandle | undefined { + const waiter = this.promptWaiters.get(id); + if (waiter === undefined) return undefined; + const projection = this.promptProjection(id); + const state = (): PromptState => this.promptStateOf(id); + const handle: PromptHandle = { + id, + userMessageId: projection?.userMessageId ?? '', + createdAt: projection?.createdAt ?? '', + get state() { + return state(); + }, + message: projection?.message ?? EMPTY_HANDLE_MESSAGE, + launched: waiter.launched, + completion: waiter.completion, + }; + if (this.terminalStates.has(id)) this.promptWaiters.delete(id); + return handle; + } + + private promptStateOf(id: string): PromptState { + if (this.active?.prompt.id === id) return 'running'; + if (this.steered.has(id)) return 'steered'; + return this.terminalStates.get(id) ?? 'pending'; + } + + private promptProjection(id: string): PromptProjection | undefined { + const failedEntry = this.promptWaiters.get(id)?.failedEntry; + if (failedEntry !== undefined) return projectionFromEntry(failedEntry); + const active = this.active; + if (active !== undefined && active.prompt.id === id) return active.prompt; + const steered = this.steered.get(id); + if (steered !== undefined) return steered; + const pending = this.pendingMachineTurn; + if (pending?.queueItemId === id && pending.entry !== undefined) { + return projectionFromEntry(pending.entry); + } + const queued = this.engine + ?.snapshot() + .queue.find((item) => item.meta?.promptId === id); + if (queued !== undefined) return projectionFromEntry(queued); + const parked = this.pendingSubmissions.find((item) => item.meta?.promptId === id); + if (parked !== undefined) return projectionFromEntry(parked); + return undefined; + } + + notify(note: LoopNotify = {}): LoopNotifyHandle { + if (this.disposing) throw abortError('Agent loop disposed'); + const nudge: Nudge = { + contextMessage: note.message, + bypassMaxSteps: note.bypassMaxSteps ?? false, + turnScoped: note.turnScoped ?? true, + onConsume: note.onConsume, + onDrop: note.onDrop, + }; + this.nudges.push(nudge); + if (this.quiescenceDepth === 0 && this.engine !== undefined) { + nudge.sentToMachine = true; + this.machineEngine().notify(createUserEntry(machineUserMessage(note.message))); + } + return { + get dropped() { + return nudge.dropped === true; + }, + drop: () => { + if (nudge.dropped === true || nudge.consumed === true) return; + nudge.dropped = true; + nudge.onDrop?.(); + this.maybeSettle(); + }, + }; + } + + private createWaiter( + id: string, + dispatchPromptId?: string, + onMaterialize?: () => void, + ): PromptWaiter { + return { + id, + dispatchPromptId, + launched: createControlledPromise(), + completion: createControlledPromise(), + onMaterialize, + }; + } + + private machinePaused(): boolean { + return this.engine?.snapshot().paused ?? false; + } + + snapshot(): LoopSnapshot { + const engine = this.engine; + const engineSnapshot = engine?.snapshot(); + const machineQueue = engineSnapshot?.queue ?? []; + const parked = this.pendingSubmissions.filter( + (entry) => !machineQueue.some((item) => item.meta?.promptId === entry.meta?.promptId), + ); + const queue = [...machineQueue, ...parked]; + const turn = engineSnapshot?.turn; + return { + state: this.active === undefined ? 'idle' : 'running', + activeTurnId: this.active?.id, + activePromptId: + this.active !== undefined && this.active.prompt.tracked ? this.active.prompt.id : undefined, + queue, + notificationCount: engineSnapshot?.notificationCount ?? 0, + paused: engineSnapshot?.paused ?? false, + hasPendingRequests: this.hasPendingRequests(), + turn: + turn === undefined + ? undefined + : { + turnId: turn.turnId, + phase: turn.phase, + step: turn.step, + ending: engineSnapshot?.aborting ?? false, + endingReason: engineSnapshot?.aborting === true ? 'aborted' : undefined, + retry: turn.retry, + activeToolCalls: turn.activeToolCalls, + since: this.active?.startedAt, + }, + activeTraceId: this.activeRequestTrace?.traceId, + }; + } + + private settlePromptLaunched(waiter: PromptWaiter, active: ActiveTurn): void { + waiter.launched.resolve(active.turn); + void active.turn.result.then((result) => + this.settlePromptCompletion(waiter, active.prompt, result), + ); + if (!active.prompt.tracked) return; + this.publishPromptStarted(active.prompt.id, active.prompt.origin); + } + + private settlePromptCompletion( + waiter: PromptWaiter, + prompt: ActivePrompt, + result: TurnResult, + ): void { + const state = + result.type === 'cancelled' ? 'cancelled' : result.type === 'failed' ? 'failed' : 'completed'; + waiter.completion.resolve({ + promptId: waiter.id, + result, + state, + }); + for (const [childId, steeredEntry] of this.steered) { + if (steeredEntry.parentId !== waiter.id) continue; + const child = this.promptWaiters.get(childId); + if (child !== undefined) { + child.completion.resolve({ + promptId: childId, + result, + state, + }); + this.promptWaiters.delete(childId); + } + this.terminalStates.set(childId, state); + this.steered.delete(childId); + } + if (prompt.tracked) { + if (state === 'cancelled') this.publishPromptAborted(waiter.id); + else this.publishPromptCompleted(waiter.id, state); + } + this.terminalStates.set(waiter.id, state); + this.promptWaiters.delete(waiter.id); + } + + private async materializeDaemonRefs(message: { + readonly content: readonly ContentPart[]; + }): Promise { + if (!message.content.some((part) => daemonFileRefFromPart(part) !== undefined)) return; + const files = this.instantiation.invokeFunction((accessor) => accessor.get(IFileService)); + const mediaStore = this.instantiation.invokeFunction((accessor) => + accessor.get(ISessionMediaStore), + ); + await materializePromptDaemonRefs(message.content, { files, mediaStore }); + } + + private async runPromptGate( + queueItemId: string | undefined, + message: UserMessage, + ): Promise { + const waiter = queueItemId === undefined ? undefined : this.promptWaiters.get(queueItemId); + const entry = + queueItemId === undefined + ? undefined + : this.machineEngine().snapshot().queue.find((item) => item.meta?.promptId === queueItemId); + if (waiter === undefined || entry?.meta?.tracked !== true) { + return false; + } + const promptMessage: ContextMessage = { + role: 'user', + content: [...message.content], + toolCalls: [], + id: queueItemId, + origin: entry.meta?.origin as PromptOrigin | undefined, + }; + const ctx: PromptSubmitContext = { + promptMessage, + isSteer: false, + block: false, + }; + await this.hooks.onBeforeSubmitPrompt.run(ctx); + if (ctx.block) return { block: true }; + await this.materializeDaemonRefs(promptMessage); + return { + block: false, + message: { + role: 'user', + content: gateImageFormatParts(promptMessage.content, this.profile.getModelProviderType()), + }, + }; + } + + private settleGateRejectedPrompt( + queueItemId: string | undefined, + entry: UserEntry | undefined, + state: 'blocked' | 'failed', + ): void { + const waiter = queueItemId === undefined ? undefined : this.promptWaiters.get(queueItemId); + if (waiter === undefined) return; + if (state === 'blocked' && entry !== undefined && entry.message.content.length > 0) { + this.context.append({ + role: 'user', + content: [...entry.message.content], + id: waiter.id, + toolCalls: [], + origin: entry.meta?.origin as PromptOrigin | undefined, + }); + } + waiter.launched.resolve(undefined); + waiter.completion.resolve({ + promptId: waiter.id, + result: undefined, + state, + }); + this.publishPromptCompleted(waiter.id, state); + this.terminalStates.set(waiter.id, state); + this.promptWaiters.delete(waiter.id); + this.maybeSettle(); + } + + + private async deliverToolResult(ctx: ToolDidExecuteContext): Promise { + const delivery = ctx.result.delivery; + if (delivery === undefined) return; + const { delivery: _delivery, ...rest } = ctx.result; + ctx.result = rest as ExecutableToolResult; + if (delivery.kind === 'steer') { + const message = delivery.message as ContextMessage; + this.submit( + { message: machineUserMessage(message), meta: { origin: message.origin } }, + { steerIfActive: true }, + ); + } + } + + private publishPromptCompleted(promptId: string, reason: 'completed' | 'failed' | 'blocked'): void { + void this.dispatcher.dispatch( + new PromptCompleted({ + agentId: this.scopeContext.agentId, + promptId, + finishedAt: new Date().toISOString(), + reason, + }), + ); + } + + private publishPromptQueued(input: { + readonly promptId: string; + readonly origin: PromptOrigin; + readonly message: ContextMessage; + }): void { + if (input.origin.kind !== 'user') return; + void this.dispatcher.dispatch( + new PromptQueued({ + agentId: this.scopeContext.agentId, + promptId: input.promptId, + content: stripBundledSkillBlocks(input.message), + clientMetadata: input.origin.clientMetadata, + queueLength: (this.engine?.snapshot().queue.length ?? 0) + 1, + }), + ); + } + + private publishPromptSubmitted( + input: { + readonly promptId: string; + readonly origin: PromptOrigin; + readonly userMessageId: string; + readonly createdAt: string; + readonly message: ContextMessage; + }, + status: 'running' | 'queued', + ): void { + if (input.origin.kind !== 'user') return; + void this.dispatcher.dispatch( + new PromptSubmitted({ + agentId: this.scopeContext.agentId, + promptId: input.promptId, + userMessageId: input.userMessageId, + status, + content: stripBundledSkillBlocks(input.message), + clientMetadata: input.origin.clientMetadata, + createdAt: input.createdAt, + }), + ); + } + + private publishPromptStarted(promptId: string, origin: PromptOrigin): void { + if (origin.kind !== 'user') return; + void this.dispatcher.dispatch( + new PromptStarted({ + agentId: this.scopeContext.agentId, + promptId, + }), + ); + } + + private publishPromptAborted(promptId: string): void { + void this.dispatcher.dispatch( + new PromptAborted({ + agentId: this.scopeContext.agentId, + promptId, + abortedAt: new Date().toISOString(), + }), + ); + } + + cancel(target?: LoopCancelTarget, reason?: unknown): boolean { + const cancellation = reason ?? userCancellationReason(); + if (target?.promptId !== undefined) { + const active = this.active; + if (active !== undefined && active.prompt.tracked && active.prompt.id === target.promptId) { + return this.cancelActiveTurn(undefined, cancellation); + } + const waiter = this.promptWaiters.get(target.promptId); + if (waiter === undefined) { + throw new Error2(ErrorCodes.PROMPT_NOT_FOUND, `prompt ${target.promptId} not found`); + } + return this.cancelWaiter(waiter, cancellation); + } + return this.cancelActiveTurn(target?.turnId, cancellation); + } + + private cancelWaiter(waiter: PromptWaiter, cancellation: unknown): boolean { + const active = this.active; + if (active !== undefined && active.prompt.id === waiter.id) { + return this.cancelActiveTurn(undefined, cancellation); + } + const tracked = this.promptProjection(waiter.id)?.tracked === true; + this.engine?.cancelQueueItem(waiter.id); + this.settleWaiterCancelled(waiter); + if (tracked) { + this.publishPromptAborted(waiter.id); + } + this.terminalStates.set(waiter.id, 'cancelled'); + this.promptWaiters.delete(waiter.id); + this.steered.delete(waiter.id); + return true; + } + + private settleWaiterCancelled(waiter: PromptWaiter): void { + waiter.launched.resolve(undefined); + waiter.completion.resolve({ + promptId: waiter.id, + result: undefined, + state: 'cancelled', + }); + this.maybeSettle(); + } + + tryAcquireQuiescence(): IDisposable | undefined { + if (this.disposing) throw abortError('Agent loop disposed'); + if ( + this.quiescenceDepth > 0 || + this.active !== undefined || + this.hasPendingRequests() || + this.pendingMachineTurn !== undefined + ) { + return undefined; + } + this.quiescenceDepth += 1; + this.engine?.pause(); + return toDisposable(() => this.releaseQuiescence()); + } + + private releaseQuiescence(): void { + if (this.quiescenceDepth === 0) return; + this.quiescenceDepth -= 1; + if (this.quiescenceDepth > 0 || this.disposing) return; + this.engine?.resume(); + this.drainPendingToMachine(); + this.maybeSettle(); + } + + private drainPendingToMachine(): void { + if (this.engine === undefined) return; + const queued = new Set(this.engine.snapshot().queue.map((item) => item.meta?.promptId)); + for (const entry of this.pendingSubmissions.splice(0)) { + const id = entry.meta?.promptId; + if (id === undefined || queued.has(id) || !this.promptWaiters.has(id)) continue; + this.machineEngine().submit(entry); + } + if (this.quiescenceDepth > 0) return; + for (const nudge of this.nudges.slice(this.nudgeCursor)) { + if (!nudge.dropped && !nudge.sentToMachine) { + nudge.sentToMachine = true; + this.machineEngine().notify(createUserEntry(machineUserMessage(nudge.contextMessage))); + } + } + } + + async resetMachineEngine(): Promise { + if (this.disposing) return; + if (this.active !== undefined || this.pendingMachineTurn !== undefined) { + throw new BugIndicatingError('Machine engine reset requires a quiescent loop'); + } + await this.machineEngine().resetJournal(this.freshEngineJournal()); + } + + private freshEngineJournal(): ReturnType { + return engineJournal( + wireStoreJournal(this.wire, ENGINE_JOURNAL_DOMAIN), + this.states.get(turnKey).nextTurnId, + ); + } + + private cancelActiveTurn(turnId: number | undefined, cancellation: unknown): boolean { + const active = this.active; + if (active === undefined || (turnId !== undefined && active.id !== turnId)) return false; + if (active.controller.signal.aborted) { + this.machineEngine().abort(active.controller.signal.reason); + return true; + } + void this.dispatcher.dispatch( + new TurnCancel({ + agentId: this.scopeContext.agentId, + turnId: active.id, + target: 'active', + reason: cancelReasonFor(cancellation), + }), + ); + active.controller.abort(cancellation); + this.machineEngine().abort(cancellation); + return true; + } + + private settleUnboundRecord( + pending: { readonly id: number; readonly queueItemId?: string; readonly entry?: UserEntry }, + outcome: { readonly outcome: MachineTurnOutcome; readonly error?: unknown }, + ): void { + const active = this.active; + if (active !== undefined) { + active.afterChain = active.afterChain.then(() => { + this.settleUnboundRecord(pending, outcome); + }); + return; + } + if (pending.queueItemId === undefined) { + const seeded = this.nudges.slice(this.nudgeCursor).find( + (nudge) => !nudge.dropped && nudge.contextMessage !== undefined && nudge.contextMessage.content.length > 0, + ); + if (seeded === undefined) { + this.consumeDrainedNudges(); + return; + } + const seededMessage = seeded.contextMessage as ContextMessage; + const waiter = this.createWaiter(seededMessage.id ?? newMessageId(), seededMessage.id); + this.terminalStates.delete(waiter.id); + this.promptWaiters.set(waiter.id, waiter); + const entry: UserEntry = { + message: { role: 'user', content: [...seededMessage.content] }, + meta: { promptId: waiter.id, origin: seededMessage.origin, tracked: false }, + }; + const seededTurn = this.beginActiveTurn(waiter, entry, pending.id); + this.mirrorConsumedNudges(seededTurn); + this.endPreGateTurn(seededTurn, outcome); + return; + } + const waiter = this.promptWaiters.get(pending.queueItemId); + if (waiter === undefined || pending.entry === undefined) return; + const boundTurn = this.beginActiveTurn(waiter, pending.entry, pending.id); + waiter.onMaterialize?.(); + this.materializeMessage(this.gatedProjectionMessage(boundTurn.prompt)); + this.settlePromptLaunched(waiter, boundTurn); + this.endPreGateTurn(boundTurn, outcome); + } + + private endPreGateTurn( + turn: ActiveTurn, + outcome: { readonly outcome: MachineTurnOutcome; readonly error?: unknown }, + ): void { + if (outcome.outcome === 'aborted') { + const reason = turn.controller.signal.aborted + ? turn.controller.signal.reason + : abortError('Turn aborted'); + turn.controller.abort(reason); + turn.afterChain = turn.afterChain.then(() => + this.endTurn(turn, { type: 'cancelled', steps: 0, reason }), + ); + return; + } + const error = outcome.error ?? new Error2(ErrorCodes.INTERNAL, 'Turn ended before first step'); + turn.afterChain = turn.afterChain.then(() => + this.endTurn(turn, { type: 'failed', steps: 0, error }), + ); + } + + private hasPendingRequests(): boolean { + return ( + this.pendingSubmissions.length > 0 || + (this.engine?.snapshot().queue.length ?? 0) > 0 || + this.nudges.slice(this.nudgeCursor).some((nudge) => !nudge.dropped) + ); + } + + settled(): Promise { + if ( + this.active === undefined && + !this.hasPendingRequests() && + this.pendingMachineTurn === undefined + ) { + return Promise.resolve(); + } + return new Promise((resolve) => { + this.settleWaiters.push(resolve); + }); + } + + private maybeSettle(): void { + if ( + this.active !== undefined || + this.pendingMachineTurn !== undefined || + this.hasPendingRequests() + ) return; + if (this.settleWaiters.length === 0) return; + const waiters = this.settleWaiters.splice(0); + for (const resolve of waiters) resolve(); + } + + registerLoopErrorHandler( + handler: LoopErrorHandler, + options: LoopErrorHandlerRegistrationOptions = {}, + ): IDisposable { + if (options.before !== undefined && options.after !== undefined) { + throw new BugIndicatingError('Loop error handler registration cannot specify both before and after'); + } + this.deleteErrorHandler(handler.id); + const target = options.before ?? options.after; + if (target === undefined) { + this.errorHandlers.push(handler); + } else { + const targetIndex = this.errorHandlers.findIndex((entry) => entry.id === target); + if (targetIndex < 0) { + throw new BugIndicatingError(`Loop error handler target "${target}" is not registered`); + } + const insertAt = options.before !== undefined ? targetIndex : targetIndex + 1; + this.errorHandlers.splice(insertAt, 0, handler); + } + return toDisposable(() => { + this.deleteErrorHandler(handler.id); + }); + } + + private deleteErrorHandler(id: string): boolean { + const index = this.errorHandlers.findIndex((entry) => entry.id === id); + if (index < 0) return false; + this.errorHandlers.splice(index, 1); + return true; + } + + private async gate(machineSignal: AbortSignal): Promise { + const active = this.active; + if (active !== undefined) await active.afterChain; + const pending = this.pendingMachineTurn; + if (pending !== undefined) { + this.pendingMachineTurn = undefined; + if (!this.bindMachineTurn(pending)) return { type: 'fail' }; + } + const turn = this.active; + if (turn === undefined) return { type: 'fail' }; + if (turn.controller.signal.aborted || machineSignal.aborted) return { type: 'fail' }; + if (turn.stopRequested) return { type: 'fail' }; + if (turn.failedStep !== undefined) return { type: 'fail' }; + const consumed = this.mirrorConsumedNudges(turn); + if (turn.steerController.signal.aborted) { + turn.steerController = new AbortController(); + } + if (turn.toolStopRequested && consumed.live === 0) return { type: 'fail' }; + const stepOrdinal = Math.max(this.engine?.currentStep() ?? 0, turn.steps + 1); + const maxSteps = this.config.get(LOOP_CONTROL_SECTION)?.maxStepsPerTurn; + if ( + maxSteps !== undefined && + maxSteps > 0 && + stepOrdinal > maxSteps && + !consumed.bypass + ) { + turn.maxStepsError = createMaxStepsExceededError(maxSteps); + return { type: 'fail' }; + } + turn.steps = stepOrdinal; + turn.gatedSteps = stepOrdinal; + const step: MachineStepState = { + number: stepOrdinal, + uuid: randomUUID(), + signal: turn.controller.signal, + contentAppended: false, + entry: undefined, + usage: undefined, + timing: undefined, + providerFinishReason: undefined, + rawFinishReason: undefined, + messageId: undefined, + pendingToolIds: new Set(), + toolCallUuids: new Map(), + resolvedToolIds: new Set(), + toolStopTurn: false, + }; + turn.current = step; + turn.interruptStep = step.number; + this.activeRequestTrace = undefined; + this.telemetry.setContext({ trace_id: undefined }); + EventEmitter.setMaxListeners(MAX_STEP_SIGNAL_LISTENERS, turn.controller.signal); + try { + + await this.hooks.onWillBeginStep.run({ + turnId: turn.id, + step: stepOrdinal, + firstStepOfTurn: stepOrdinal === 1, + signal: step.signal, + }); + + } catch (error) { + + return this.failMachineGate(turn, step, error); + } + if (step.signal.aborted) { + return this.failMachineGate(turn, step, step.signal.reason ?? abortError('Step aborted')); + } + return { type: 'proceed', signal: step.signal, step: step.number }; + } + + private failMachineGate( + turn: ActiveTurn, + step: MachineStepState, + error: unknown, + ): MachineGateDecision { + if (turn.controller.signal.aborted || isAbortError(error) || step.signal.aborted) { + turn.abortReason = turn.controller.signal.aborted ? turn.controller.signal.reason : error; + return { type: 'fail' }; + } + turn.failedStep = { + number: step.number, + uuid: step.uuid, + error, + }; + return { type: 'fail' }; + } + + private bindMachineTurn(pending: { + readonly id: number; + readonly queueItemId?: string; + readonly entry?: UserEntry; + }): boolean { + if (this.active !== undefined) { + if (pending.queueItemId !== undefined && pending.entry !== undefined) { + const waiter = this.promptWaiters.get(pending.queueItemId); + if (waiter !== undefined) { + this.machineEngine().submit({ + message: this.gatedEntryMessage(pending.entry), + meta: pending.entry.meta, + }); + } + } + return true; + } + if (pending.queueItemId !== undefined) { + const waiter = this.promptWaiters.get(pending.queueItemId); + if (waiter === undefined || pending.entry === undefined) { + this.machineTurnSuppressed = true; + return false; + } + const boundTurn = this.beginActiveTurn(waiter, pending.entry, pending.id); + waiter.onMaterialize?.(); + this.materializeMessage(this.gatedProjectionMessage(boundTurn.prompt)); + this.settlePromptLaunched(waiter, boundTurn); + return true; + } + const seeded = this.nudges.slice(this.nudgeCursor).find( + (nudge) => !nudge.dropped && nudge.contextMessage !== undefined && nudge.contextMessage.content.length > 0, + ); + if (seeded === undefined) { + this.machineTurnSuppressed = true; + return false; + } + const seededMessage = seeded.contextMessage as ContextMessage; + const waiter = this.createWaiter(seededMessage.id ?? newMessageId(), seededMessage.id); + this.promptWaiters.set(waiter.id, waiter); + const entry: UserEntry = { + message: { role: 'user', content: [...seededMessage.content] }, + meta: { promptId: waiter.id, origin: seededMessage.origin, tracked: false }, + }; + this.beginActiveTurn(waiter, entry, pending.id); + return true; + } + + private gatedProjectionMessage(prompt: ActivePrompt): ContextMessage { + if (!prompt.tracked) return prompt.message; + return { + ...prompt.message, + content: gateImageFormatParts(prompt.message.content, this.profile.getModelProviderType()), + }; + } + + private gatedEntryMessage(entry: UserEntry): UserMessage { + if (entry.meta?.tracked !== true) return { role: 'user', content: [...entry.message.content] }; + return { + role: 'user', + content: gateImageFormatParts(entry.message.content, this.profile.getModelProviderType()), + }; + } + + private beginActiveTurn(waiter: PromptWaiter, entry: UserEntry, id: number): ActiveTurn { + const origin = (entry.meta?.origin as PromptOrigin | undefined) ?? { kind: 'user' }; + const tracked = entry.meta?.tracked === true; + const prompt: ActivePrompt = { + id: waiter.id, + promptId: tracked ? waiter.id : waiter.dispatchPromptId, + tracked, + origin, + message: { + role: 'user', + content: [...entry.message.content], + id: waiter.id, + toolCalls: [], + origin: entry.meta?.origin as PromptOrigin | undefined, + }, + userMessageId: entry.meta?.userMessageId ?? '', + createdAt: entry.meta?.createdAt ?? '', + }; + const controller = new AbortController(); + const ready = createControlledPromise(); + const result = createControlledPromise(); + void ready.catch(() => undefined); + const turn: MutableTurn = { + id, + state: 'queued', + signal: controller.signal, + ready, + result, + cancel: (reason) => { + if (this.active?.turn === turn) { + return this.cancelActiveTurn(undefined, reason ?? userCancellationReason()); + } + return true; + }, + }; + const active: ActiveTurn = { + id, + prompt, + controller, + steerController: new AbortController(), + turn, + ready, + result, + startedAt: Date.now(), + steps: 0, + gatedSteps: 0, + nudgeCursor: this.nudgeCursor, + current: undefined, + interruptStep: undefined, + failedStep: undefined, + stopRequested: false, + toolStopRequested: false, + forcedStopReason: undefined, + lastStopReason: undefined, + filtered: false, + maxStepsError: undefined, + abortReason: undefined, + retryRequested: false, + afterChain: Promise.resolve(), + partials: [], + forceContentPartBoundary: false, + readyResolved: false, + mode: undefined, + providerType: undefined, + protocol: undefined, + }; + this.active = active; + active.readyResolved = true; + ready.resolve(); + active.mode = this.telemetry.getContext().mode; + const { provider_type, protocol } = this.telemetry.getContext(); + active.providerType = provider_type; + active.protocol = protocol; + this.telemetry.setContext({ turn_id: id }); + const thinkingEffort = this.llmRequester.prepareTurnConfig(id)?.thinkingEffort; + this.telemetry.setContext({ thinking_effort: thinkingEffort }); + void this.dispatcher.dispatch( + new TurnPrompt({ + agentId: this.scopeContext.agentId, + input: prompt.message.content, + origin: prompt.origin, + promptId: prompt.promptId, + turnId: id, + }), + ); + turn.state = 'running'; + void this.dispatcher.dispatch( + new TurnStarted({ + agentId: this.scopeContext.agentId, + turnId: id, + promptId: prompt.promptId, + origin: prompt.origin, + prompt: isDisplayablePromptOrigin(prompt.origin) + ? turnPromptText(prompt.message.content, prompt.origin) + : undefined, + promptAttachments: turnPromptAttachments(prompt.message.content, prompt.origin), + }), + ); + const started: TurnStartedTelemetryEvent = { + turn_id: id, + mode: active.mode ?? 'agent', + provider_type, + protocol, + }; + this.telemetry.track2('turn_started', started); + return active; + } + + private materializeMessage(message: ContextMessage): void { + if (message.content.length === 0) return; + this.context.append(message); + } + + private consumeDrainedNudges(): { readonly live: number; readonly bypass: boolean } { + const engine = this.engine; + if (engine === undefined) return { live: 0, bypass: false }; + const notificationCount = engine.snapshot().notificationCount; + let consumed = this.nudges.length - this.nudgeCursor - notificationCount; + let live = 0; + let bypass = false; + while (consumed > 0 && this.nudgeCursor < this.nudges.length) { + const nudge = this.nudges[this.nudgeCursor]!; + this.nudgeCursor += 1; + consumed -= 1; + if (nudge.dropped) continue; + live += 1; + bypass = bypass || nudge.bypassMaxSteps; + nudge.consumed = true; + if (nudge.contextMessage !== undefined && nudge.contextMessage.content.length > 0) { + this.materializeMessage(nudge.contextMessage); + } + nudge.onConsume?.(); + } + return { live, bypass }; + } + + private mirrorConsumedNudges(turn: ActiveTurn): { readonly live: number; readonly bypass: boolean } { + const consumed = this.consumeDrainedNudges(); + turn.nudgeCursor = this.nudgeCursor; + return consumed; + } + + private projectMachineEvent(event: MachineEngineEvent): void { + switch (event.type) { + case 'turnStarted': { + this.pendingMachineTurn = { + id: event.machineTurnId, + queueItemId: event.queueItemId, + entry: event.entry, + }; + this.machineTurnSuppressed = false; + return; + } + case 'promptBlocked': { + this.settleGateRejectedPrompt(event.queueItemId, event.entry, 'blocked'); + return; + } + case 'promptGateFailed': { + this.settleGateRejectedPrompt(event.queueItemId, event.entry, 'failed'); + return; + } + case 'promptSteered': { + const active = this.active; + if (active === undefined) return; + const children: { readonly waiter: PromptWaiter; readonly projection: SteeredPrompt }[] = []; + for (const entry of event.entries) { + const id = entry.meta?.promptId; + if (id === undefined) continue; + const waiter = this.promptWaiters.get(id); + if (waiter === undefined) continue; + const origin = (entry.meta?.origin as PromptOrigin | undefined) ?? { kind: 'user' }; + children.push({ + waiter, + projection: { + parentId: active.prompt.id, + tracked: entry.meta?.tracked === true, + origin, + message: { + role: 'user', + content: [...entry.message.content], + id, + toolCalls: [], + origin: entry.meta?.origin as PromptOrigin | undefined, + }, + userMessageId: entry.meta?.userMessageId ?? '', + createdAt: entry.meta?.createdAt ?? '', + }, + }); + } + if (children.length === 0) return; + for (const { waiter, projection } of children) { + this.steered.set(waiter.id, projection); + waiter.launched.resolve(active.turn); + } + active.steerController.abort(abortError('Steered by new input')); + const merged = + children.length === 1 + ? { + content: children[0]!.projection.message.content, + origin: children[0]!.projection.origin, + } + : mergeSteerMessages( + children.map((child) => ({ + content: child.projection.message.content, + origin: child.projection.origin, + })), + ); + const gatedContent = gateImageFormatParts( + merged.content, + this.profile.getModelProviderType(), + ); + this.nudges.push({ + contextMessage: { + role: 'user', + content: gatedContent, + toolCalls: [], + origin: merged.origin, + id: newMessageId(), + }, + bypassMaxSteps: false, + turnScoped: false, + sentToMachine: true, + }); + void this.dispatcher.dispatch( + new PromptSteered({ + agentId: this.scopeContext.agentId, + activePromptId: active.prompt.id, + promptIds: children.map((child) => child.waiter.id), + content: children.flatMap((child) => + stripBundledSkillBlocks(child.projection.message), + ), + steeredAt: new Date().toISOString(), + }), + ); + void this.dispatcher.dispatch( + new TurnSteer({ + agentId: this.scopeContext.agentId, + input: gatedContent, + origin: merged.origin, + }), + ); + return; + } + case 'turnSettled': { + const outcome = event; + const active = this.active; + if (this.machineTurnSuppressed) { + this.machineTurnSuppressed = false; + this.maybeSettle(); + return; + } + if (this.pendingMachineTurn !== undefined) { + const pending = this.pendingMachineTurn; + this.pendingMachineTurn = undefined; + this.machineTurnSuppressed = false; + this.settleUnboundRecord(pending, outcome); + this.maybeSettle(); + return; + } + if (active === undefined) return; + active.afterChain = active.afterChain.then(() => this.evaluateSettle(active, outcome)); + return; + } + case 'stepStarted': { + const turn = this.active; + const step = turn?.current; + if (turn === undefined || step === undefined) return; + if (!turn.readyResolved) { + turn.readyResolved = true; + turn.ready.resolve(); + } + void this.dispatcher.dispatch( + new TurnStepStarted({ + agentId: this.scopeContext.agentId, + turnId: turn.id, + step: step.number, + stepId: step.uuid, + }), + ); + this.context.appendLoopEvent({ + type: 'step.begin', + uuid: step.uuid, + turnId: String(turn.id), + step: step.number, + }); + turn.partials = []; + turn.forceContentPartBoundary = false; + return; + } + case 'delta': { + const turn = this.active; + if (turn === undefined) return; + const delta = event.delta; + switch (delta.kind) { + case 'assistant': + this.accumulateMachinePart(turn, { type: 'text', text: delta.delta }); + void this.dispatcher.dispatch( + new AssistantDelta({ agentId: this.scopeContext.agentId, turnId: turn.id, delta: delta.delta }), + ); + return; + case 'thinking': { + const part = this.accumulateMachinePart(turn, { + type: 'think', + think: delta.delta, + encrypted: delta.encrypted, + detailsIndex: delta.detailsIndex, + hidden: delta.hidden, + }); + if (part?.type === 'think' && part.hidden === true) return; + void this.dispatcher.dispatch( + new ThinkingDelta({ agentId: this.scopeContext.agentId, turnId: turn.id, delta: delta.delta }), + ); + return; + } + case 'toolCall': + if (delta.started === true) turn.forceContentPartBoundary = true; + void this.dispatcher.dispatch( + new ToolCallDelta({ + agentId: this.scopeContext.agentId, + turnId: turn.id, + toolCallId: delta.toolCallId, + name: delta.name, + argumentsPart: delta.argumentsPart, + }), + ); + return; + } + return; + } + case 'stepCompleted': { + const turn = this.active; + const step = turn?.current; + if (turn === undefined || step === undefined) return; + step.entry = event.entry; + step.usage = event.usage; + step.timing = event.timing; + step.providerFinishReason = event.finish?.finishReason ?? undefined; + step.rawFinishReason = event.finish?.rawFinishReason ?? undefined; + step.messageId = event.messageId; + for (const part of event.entry.message.content) { + this.context.appendLoopEvent({ + type: 'content.part', + uuid: randomUUID(), + turnId: String(turn.id), + step: step.number, + stepUuid: step.uuid, + part, + }); + } + step.contentAppended = true; + this.lastRequestTraceId = this.activeRequestTrace?.traceId; + const toolCalls = event.entry.message.toolCalls; + if (toolCalls.length === 0) { + const finishReason = step.providerFinishReason ?? 'completed'; + this.endOrInterruptMachineStep(turn, step, finishReason === 'tool_calls' ? 'other' : finishReason); + } else { + step.pendingToolIds = new Set(toolCalls.map((call) => call.id)); + } + return; + } + case 'toolStarted': { + const turn = this.active; + const step = turn?.current; + if (turn === undefined || step === undefined) return; + const callUuid = randomUUID(); + step.toolCallUuids.set(event.toolCallId, callUuid); + const extras = step.entry?.message.toolCalls.find((call) => call.id === event.toolCallId)?.extras; + this.context.appendLoopEvent({ + type: 'tool.call', + uuid: callUuid, + turnId: String(turn.id), + step: step.number, + stepUuid: step.uuid, + toolCallId: event.toolCallId, + name: event.name, + args: event.args, + extras, + display: event.display, + }); + return; + } + case 'toolDone': { + const turn = this.active; + const step = turn?.current; + if (turn === undefined || step === undefined) return; + step.pendingToolIds.delete(event.toolCallId); + if (this.isCannedUnknownToolResult(step, event.toolCallId, event.result)) { + turn.afterChain = turn.afterChain.then(async () => { + await this.executeUnknownToolCall(turn, step, event.toolCallId); + if (turn.current === step && step.pendingToolIds.size === 0) { + this.endOrInterruptMachineStep(turn, step, step.toolStopTurn ? 'completed' : 'tool_calls'); + } + }); + return; + } + if (step.pendingToolIds.size === 0) { + this.endOrInterruptMachineStep(turn, step, step.toolStopTurn ? 'completed' : 'tool_calls'); + } + return; + } + case 'toolFailed': { + const turn = this.active; + const step = turn?.current; + if (turn === undefined || step === undefined) return; + const message = event.error instanceof Error ? event.error.message : String(event.error); + this.context.appendLoopEvent({ + type: 'tool.result', + parentUuid: step.toolCallUuids.get(event.toolCallId) ?? randomUUID(), + toolCallId: event.toolCallId, + result: { output: message, isError: true }, + }); + step.resolvedToolIds.add(event.toolCallId); + step.pendingToolIds.delete(event.toolCallId); + if (step.pendingToolIds.size === 0) { + this.endOrInterruptMachineStep(turn, step, step.toolStopTurn ? 'completed' : 'tool_calls'); + } + return; + } + case 'toolBatchFailed': { + const turn = this.active; + const step = turn?.current; + if (turn === undefined || step === undefined) return; + if (step.signal.aborted) return; + this.closeFailedMachineStep(turn, step, 'error'); + turn.failedStep ??= { + number: step.number, + uuid: step.uuid, + error: event.error, + }; + turn.current = undefined; + this.machineEngine().abort(); + return; + } + case 'recovering': { + const turn = this.active; + const step = turn?.current; + if (turn === undefined) return; + if (step !== undefined) { + this.closeFailedMachineStep(turn, step, 'error'); + } + turn.current = undefined; + return; + } + case 'retrying': { + const turn = this.active; + const step = turn?.current; + if (turn === undefined) return; + if (step !== undefined) { + this.closeFailedMachineStep(turn, step, 'error'); + } + const fields = + event.rawError !== undefined + ? retryErrorFields(event.rawError) + : { + errorName: event.errorName, + errorMessage: event.errorMessage, + statusCode: event.statusCode, + }; + void this.dispatcher.dispatch( + new TurnStepRetrying({ + agentId: this.scopeContext.agentId, + turnId: turn.id, + step: step?.number ?? turn.gatedSteps, + stepId: step?.uuid, + failedAttempt: event.failedAttempt, + nextAttempt: event.nextAttempt, + maxAttempts: event.maxAttempts, + delayMs: event.delayMs, + errorName: fields.errorName, + errorMessage: fields.errorMessage, + statusCode: fields.statusCode, + }), + ); + turn.current = undefined; + return; + } + case 'stepFailed': { + const turn = this.active; + const step = turn?.current; + if (turn === undefined || step === undefined) return; + this.closeFailedMachineStep(turn, step, step.signal.aborted ? 'interrupted' : 'error'); + turn.failedStep ??= { + number: step.number, + uuid: step.uuid, + error: event.rawError ?? event.error, + }; + turn.current = undefined; + return; + } + default: + return; + } + } + + private isCannedUnknownToolResult( + step: MachineStepState, + toolCallId: string, + result: { readonly content: readonly ContentPart[]; readonly isError?: boolean }, + ): boolean { + if (step.toolCallUuids.has(toolCallId)) return false; + if (result.isError !== true || result.content.length !== 1) return false; + const part = result.content[0]; + const call = step.entry?.message.toolCalls.find((entry) => entry.id === toolCallId); + return ( + part !== undefined && + part.type === 'text' && + call !== undefined && + part.text === `unknown tool: ${call.name}` + ); + } + + private async executeUnknownToolCall( + turn: ActiveTurn, + step: MachineStepState, + toolCallId: string, + ): Promise { + const call = step.entry?.message.toolCalls.find((entry) => entry.id === toolCallId); + if (call === undefined) return; + try { + for await (const result of this.toolExecutor.execute([call], { + signal: turn.controller.signal, + turnId: turn.id, + trace: this.activeRequestTrace, + onToolCall: (payload) => { + const callUuid = randomUUID(); + step.toolCallUuids.set(payload.toolCallId, callUuid); + const extras = step.entry?.message.toolCalls.find( + (entry) => entry.id === payload.toolCallId, + )?.extras; + this.context.appendLoopEvent({ + type: 'tool.call', + uuid: callUuid, + turnId: String(turn.id), + step: step.number, + stepUuid: step.uuid, + toolCallId: payload.toolCallId, + name: payload.name, + args: payload.args, + extras, + }); + }, + })) { + if (result.toolCallId === toolCallId) { + this.appendMachineToolResult(toolCallId, result.result); + } + } + } catch (error) { + if (this.active !== turn || turn.current !== step || step.signal.aborted) return; + this.closeFailedMachineStep(turn, step, 'error'); + turn.failedStep ??= { + number: step.number, + uuid: step.uuid, + error, + }; + turn.current = undefined; + this.machineEngine().abort(); + } + } + + private accumulateMachinePart(turn: ActiveTurn, part: ContentPart): ContentPart | undefined { + const last = turn.partials.at(-1); + if (part.type === 'think' && last?.type === 'text' && isVacuousContentPart(part)) return undefined; + if (!turn.forceContentPartBoundary && last !== undefined && mergeInPlace(last, part)) return last; + turn.forceContentPartBoundary = false; + turn.partials.push({ ...part }); + return turn.partials.at(-1); + } + + private appendMachineToolResult( + toolCallId: string, + result: { + readonly output: string | ContentPart[]; + readonly isError?: boolean; + readonly note?: string; + readonly stopTurn?: boolean; + readonly stopTurnReason?: string; + }, + ): void { + const turn = this.active; + const step = turn?.current; + if (turn === undefined || step === undefined) return; + this.context.appendLoopEvent({ + type: 'tool.result', + parentUuid: step.toolCallUuids.get(toolCallId) ?? randomUUID(), + toolCallId, + result: { output: result.output, isError: result.isError, note: result.note }, + }); + step.resolvedToolIds.add(toolCallId); + if (result.stopTurn === true) { + step.toolStopTurn = true; + turn.toolStopRequested = true; + turn.forcedStopReason ??= result.stopTurnReason; + } + } + + private drainMachinePartials(turn: ActiveTurn, step: MachineStepState): void { + const drained = turn.partials.splice(0).filter((entry) => !isVacuousContentPart(entry)); + let lastCompleteThink = -1; + for (const [index, part] of drained.entries()) { + if (part.type === 'think' && part.encrypted !== undefined) { + lastCompleteThink = index; + } + } + for (const part of drained.filter( + (part, index) => part.type !== 'think' || index <= lastCompleteThink, + )) { + this.context.appendLoopEvent({ + type: 'content.part', + uuid: randomUUID(), + turnId: String(turn.id), + step: step.number, + stepUuid: step.uuid, + part, + }); + } + } + + private closeFailedMachineStep( + turn: ActiveTurn, + step: MachineStepState, + finishReason: 'error' | 'interrupted', + ): void { + if (!step.contentAppended) this.drainMachinePartials(turn, step); + this.context.appendLoopEvent({ + type: 'step.end', + uuid: step.uuid, + turnId: String(turn.id), + step: step.number, + finishReason, + }); + } + + private endOrInterruptMachineStep( + turn: ActiveTurn, + step: MachineStepState, + finishReason: FinishReason, + ): void { + if (turn.controller.signal.aborted) { + this.context.appendLoopEvent({ + type: 'step.end', + uuid: step.uuid, + turnId: String(turn.id), + step: step.number, + finishReason: 'interrupted', + }); + turn.current = undefined; + return; + } + this.endMachineStep(turn, step, finishReason); + } + + private endMachineStep(turn: ActiveTurn, step: MachineStepState, finishReason: FinishReason): void { + const normalized = normalizeFinishReason(finishReason); + const usage = step.usage ?? emptyUsage(); + turn.lastStopReason = finishReason; + turn.current = undefined; + const firstStepOfTurn = step.number === 1; + turn.afterChain = turn.afterChain.then(async () => { + this.finishMachineStepProjection(turn, step, normalized, usage); + await this.runMachineAfterStep(turn, step, firstStepOfTurn, usage, finishReason); + }); + } + + private finishMachineStepProjection( + turn: ActiveTurn, + step: MachineStepState, + normalized: string, + usage: TokenUsage, + ): void { + this.context.appendLoopEvent({ + type: 'step.end', + uuid: step.uuid, + turnId: String(turn.id), + step: step.number, + finishReason: normalized, + usage, + llmFirstTokenLatencyMs: step.timing?.firstTokenLatencyMs, + llmStreamDurationMs: step.timing?.streamDurationMs, + llmRequestBuildMs: step.timing?.requestBuildMs, + llmServerFirstTokenMs: step.timing?.serverFirstTokenMs, + llmServerDecodeMs: step.timing?.serverDecodeMs, + llmClientConsumeMs: step.timing?.clientConsumeMs, + llmClientBlockedMs: step.timing?.clientBlockedMs, + messageId: step.messageId, + providerFinishReason: step.providerFinishReason, + rawFinishReason: step.rawFinishReason, + }); + void this.dispatcher.dispatch( + new TurnStepCompleted({ + agentId: this.scopeContext.agentId, + turnId: turn.id, + step: step.number, + stepId: step.uuid, + usage, + finishReason: normalized, + llmFirstTokenLatencyMs: step.timing?.firstTokenLatencyMs, + llmStreamDurationMs: step.timing?.streamDurationMs, + llmRequestBuildMs: step.timing?.requestBuildMs, + llmServerFirstTokenMs: step.timing?.serverFirstTokenMs, + llmServerDecodeMs: step.timing?.serverDecodeMs, + llmClientConsumeMs: step.timing?.clientConsumeMs, + llmClientBlockedMs: step.timing?.clientBlockedMs, + providerFinishReason: step.providerFinishReason, + rawFinishReason: step.rawFinishReason, + }), + ); + } + + private async runMachineAfterStep( + turn: ActiveTurn, + step: MachineStepState, + firstStepOfTurn: boolean, + usage: TokenUsage, + finishReason: FinishReason, + ): Promise { + const context: AfterStepContext = { + turnId: turn.id, + step: step.number, + firstStepOfTurn, + signal: step.signal, + usage, + finishReason, + stopTurn: false, + }; + try { + await this.hooks.onDidFinishStep.run(context); + } catch (error) { + if (isAbortError(error) || step.signal.aborted) { + turn.abortReason = turn.controller.signal.aborted + ? turn.controller.signal.reason + : error; + return; + } + } + turn.interruptStep = undefined; + if (context.stopTurn) turn.stopRequested = true; + if (finishReason === 'filtered') turn.filtered = true; + } + + private async evaluateSettle( + turn: ActiveTurn, + outcome: { readonly outcome: MachineTurnOutcome; readonly error?: unknown }, + ): Promise { + if (this.active !== turn) return; + if ( + turn.failedStep !== undefined && + turn.abortReason === undefined && + !turn.controller.signal.aborted + ) { + await this.recoverOrFailMachineRun(turn); + return; + } + if (turn.abortReason !== undefined || turn.controller.signal.aborted || outcome.outcome === 'aborted') { + const reason = + turn.abortReason ?? + (turn.controller.signal.aborted ? turn.controller.signal.reason : undefined) ?? + abortError('Turn aborted'); + this.interruptMachineRunForCancel(turn, reason); + await this.endTurn(turn, { type: 'cancelled', steps: turn.steps, reason }); + return; + } + if (turn.filtered) { + await this.endTurn(turn, { + type: 'failed', + steps: turn.steps, + error: new Error2(ErrorCodes.PROVIDER_FILTERED, 'Provider safety policy blocked the response.', { + name: 'ProviderFilteredError', + details: { finishReason: 'filtered' }, + }), + }); + return; + } + if (turn.maxStepsError !== undefined) { + await this.endTurn(turn, { type: 'failed', steps: turn.steps, error: turn.maxStepsError }); + return; + } + if (turn.stopRequested) { + await this.endTurn(turn, this.machineCompletedResult(turn)); + return; + } + if (this.hasLiveNudge()) { + return; + } + if (turn.toolStopRequested) { + await this.endTurn(turn, this.machineCompletedResult(turn)); + return; + } + if (outcome.outcome === 'failed') { + const error = outcome.error ?? new Error('Turn failed'); + this.emitStepInterrupted(turn.id, turn.interruptStep, 'error', toErrorMessage(error)); + await this.endTurn(turn, { type: 'failed', steps: turn.steps, error }); + return; + } + await this.endTurn(turn, this.machineCompletedResult(turn)); + } + + private hasLiveNudge(): boolean { + return this.nudges.slice(this.nudgeCursor).some((nudge) => !nudge.dropped); + } + + private async recoverOrFailMachineRun(turn: ActiveTurn): Promise { + const failure = turn.failedStep!; + turn.failedStep = undefined; + const context: LoopErrorContext = { + turnId: turn.id, + step: failure.number, + stepId: failure.uuid, + signal: turn.controller.signal, + error: failure.error, + retry: () => { + turn.retryRequested = true; + }, + }; + const handler = this.errorHandlers.find((entry) => entry.match(context)); + if (handler !== undefined) { + try { + if (await handler.handle(context)) { + turn.interruptStep = undefined; + if (turn.retryRequested) { + turn.retryRequested = false; + await this.machineEngine().resetHistory(historyFromContext(this.context.get())); + this.machineEngine().notify(createUserEntry(EMPTY_MACHINE_PROMPT)); + } + return; + } + } catch (handlerError) { + if (isAbortError(handlerError) || turn.controller.signal.aborted) { + const reason = turn.controller.signal.aborted ? turn.controller.signal.reason : handlerError; + this.interruptMachineRunForCancel(turn, reason); + await this.endTurn(turn, { type: 'cancelled', steps: turn.steps, reason }); + return; + } + this.emitStepInterrupted(turn.id, failure.number, 'error', toErrorMessage(handlerError)); + await this.endTurn(turn, { type: 'failed', steps: turn.steps, error: handlerError }); + return; + } + } + this.failMachineStep(turn, failure.number, failure.error); + await this.endTurn(turn, { type: 'failed', steps: turn.steps, error: failure.error }); + } + + private failMachineStep(turn: ActiveTurn, step: number | undefined, error: unknown): void { + const reason: LoopInterruptReason = isMaxStepsExceededError(error) ? 'max_steps' : 'error'; + const interruptedError = + isError2(error) && error.code === ErrorCodes.INTERNAL && error.cause !== undefined ? error.cause : error; + this.emitStepInterrupted(turn.id, step, reason, toErrorMessage(interruptedError)); + } + + private backfillAbortedToolResults(step: MachineStepState, reason: unknown): void { + for (const toolCallId of step.pendingToolIds) { + if (step.resolvedToolIds.has(toolCallId)) continue; + const name = + step.entry?.message.toolCalls.find((call) => call.id === toolCallId)?.name ?? toolCallId; + this.context.appendLoopEvent({ + type: 'tool.result', + parentUuid: step.toolCallUuids.get(toolCallId) ?? randomUUID(), + toolCallId, + result: { output: abortedToolOutput(name, reason), isError: true }, + }); + step.resolvedToolIds.add(toolCallId); + } + } + + private interruptMachineRunForCancel(turn: ActiveTurn, reason: unknown): void { + const current = turn.current; + if (current !== undefined) { + this.backfillAbortedToolResults(current, reason); + if (!current.contentAppended) this.drainMachinePartials(turn, current); + this.context.appendLoopEvent({ + type: 'step.end', + uuid: current.uuid, + turnId: String(turn.id), + step: current.number, + finishReason: 'interrupted', + }); + turn.current = undefined; + } + if (turn.interruptStep !== undefined) { + this.emitStepInterrupted( + turn.id, + turn.interruptStep, + 'aborted', + isUserCancellation(reason) ? undefined : toErrorMessage(reason), + ); + turn.interruptStep = undefined; + } + } + + private machineCompletedResult(turn: ActiveTurn): LoopRunResult { + const truncated = turn.lastStopReason === 'truncated'; + return { + type: 'completed', + steps: turn.steps, + truncated, + stopReason: turn.forcedStopReason, + }; + } + + private async endTurn(turn: ActiveTurn, result: TurnResult): Promise { + if (this.active !== turn) return; + this.active = undefined; + await this.wire.drainPersisted().catch(() => undefined); + for (const nudge of this.nudges.slice(this.nudgeCursor)) { + if (nudge.turnScoped && !nudge.dropped) { + nudge.dropped = true; + nudge.onDrop?.(); + } + } + turn.turn.state = result.type; + if (!turn.readyResolved) { + if (result.type === 'failed') { + turn.ready.reject(result.error); + } else if (result.type === 'cancelled') { + turn.ready.reject( + result.reason instanceof Error ? result.reason : abortError('Turn cancelled'), + ); + } else { + turn.ready.reject(new Error2(ErrorCodes.INTERNAL, 'Turn ended before first step')); + } + } + const durationMs = Date.now() - turn.startedAt; + const traceId = + result.type === 'completed' ? this.lastRequestTraceId : this.activeRequestTrace?.traceId; + const error = result.type === 'failed' ? toKimiErrorPayload(result.error) : undefined; + const interruptReason = result.type === 'completed' ? undefined : interruptReasonFor(result); + void this.dispatcher.dispatch( + new TurnEnded({ + agentId: this.scopeContext.agentId, + turnId: turn.id, + reason: result.type, + error, + durationMs, + interruptReason, + stopReason: result.type === 'completed' ? result.stopReason : undefined, + }), + ); + if (error !== undefined) { + void this.dispatcher.dispatch( + new AgentErrorEvent({ ...error, agentId: this.scopeContext.agentId }), + ); + } + if (interruptReason !== undefined) { + const interrupted: TurnInterruptedEvent = { + turn_id: turn.id, + at_step: result.steps, + mode: turn.mode ?? 'agent', + interrupt_reason: interruptReason, + provider_type: turn.providerType, + protocol: turn.protocol, + trace_id: traceId, + }; + this.telemetry.track2('turn_interrupted', interrupted); + } + const ended: TurnEndedTelemetryEvent = { + turn_id: turn.id, + reason: result.type, + duration_ms: durationMs, + mode: turn.mode ?? 'agent', + error_type: error?.code, + provider_type: turn.providerType, + protocol: turn.protocol, + trace_id: traceId, + }; + this.telemetry.track2('turn_ended', ended); + this.telemetry.setContext({ turn_id: undefined, trace_id: undefined, thinking_effort: undefined }); + this.activeRequestTrace = undefined; + this.lastRequestTraceId = undefined; + turn.result.resolve(result); + this.maybeSettle(); + } + + private emitStepInterrupted( + turnId: number, + activeStep: number | undefined, + reason: LoopInterruptReason, + message?: string, + ): void { + if (activeStep === undefined) return; + void this.dispatcher.dispatch( + new TurnStepInterrupted({ + agentId: this.scopeContext.agentId, + turnId, + step: activeStep, + reason, + message, + }), + ); + } +} + +type MachineGateDecision = + | { readonly type: 'proceed'; readonly signal: AbortSignal; readonly step: number } + | { readonly type: 'fail' }; + +function normalizeFinishReason(reason: FinishReason): string { + if (reason === 'tool_calls') return 'tool_use'; + if (reason === 'completed') return 'end_turn'; + if (reason === 'truncated') return 'max_tokens'; + return reason; +} + +function machineUserMessage(message: ContextMessage | undefined): UserMessage { + if (message === undefined) return EMPTY_MACHINE_PROMPT; + return { role: 'user', content: [...message.content] }; +} + +type MutableTurn = { + -readonly [K in keyof Turn]: Turn[K]; +}; + +interface PromptWaiter { + readonly id: string; + readonly dispatchPromptId?: string; + readonly launched: ReturnType>; + readonly completion: ReturnType>; + readonly onMaterialize?: () => void; + failedEntry?: UserEntry; +} + +interface PromptProjection { + readonly tracked: boolean; + readonly origin: PromptOrigin; + readonly message: ContextMessage; + readonly userMessageId: string; + readonly createdAt: string; +} + +interface ActivePrompt extends PromptProjection { + readonly id: string; + readonly promptId?: string; +} + +interface SteeredPrompt extends PromptProjection { + readonly parentId: string; +} + +const EMPTY_HANDLE_MESSAGE: ContextMessage = { + role: 'user', + content: [], + toolCalls: [], +}; + +function projectionFromEntry(entry: UserEntry): PromptProjection { + const origin = (entry.meta?.origin as PromptOrigin | undefined) ?? { kind: 'user' }; + return { + tracked: entry.meta?.tracked === true, + origin, + message: { + role: 'user', + content: [...entry.message.content], + id: entry.meta?.promptId, + toolCalls: [], + origin: entry.meta?.origin as PromptOrigin | undefined, + }, + userMessageId: entry.meta?.userMessageId ?? '', + createdAt: entry.meta?.createdAt ?? '', + }; +} + +interface Nudge { + readonly contextMessage?: ContextMessage; + readonly bypassMaxSteps: boolean; + readonly turnScoped: boolean; + readonly onConsume?: () => void; + readonly onDrop?: () => void; + dropped?: boolean; + consumed?: boolean; + sentToMachine?: boolean; +} + +type MachineStepEntry = Extract['entry']; + +interface MachineStepState { + readonly number: number; + readonly uuid: string; + readonly signal: AbortSignal; + contentAppended: boolean; + entry: MachineStepEntry | undefined; + usage: TokenUsage | undefined; + timing: ModelRequestTiming | undefined; + providerFinishReason: FinishReason | undefined; + rawFinishReason: string | undefined; + messageId: string | undefined; + pendingToolIds: Set; + toolCallUuids: Map; + resolvedToolIds: Set; + toolStopTurn: boolean; +} + +interface MachineFailedStep { + readonly number: number; + readonly uuid: string; + readonly error: unknown; +} + +interface ActiveTurn { + readonly id: number; + readonly prompt: ActivePrompt; + readonly controller: AbortController; + steerController: AbortController; + readonly turn: MutableTurn; + readonly ready: ReturnType>; + readonly result: ReturnType>; + readonly startedAt: number; + steps: number; + gatedSteps: number; + nudgeCursor: number; + current: MachineStepState | undefined; + interruptStep: number | undefined; + failedStep: MachineFailedStep | undefined; + stopRequested: boolean; + toolStopRequested: boolean; + forcedStopReason: string | undefined; + lastStopReason: FinishReason | undefined; + filtered: boolean; + maxStepsError: LoopError | undefined; + abortReason: unknown; + retryRequested: boolean; + afterChain: Promise; + partials: ContentPart[]; + forceContentPartBoundary: boolean; + readyResolved: boolean; + mode: 'agent' | 'plan' | undefined; + providerType: string | undefined; + protocol: string | undefined; +} + +function cancelReasonFor(cancellation: unknown): 'user_cancelled' | 'aborted' { + return isUserCancellation(cancellation) ? 'user_cancelled' : 'aborted'; +} + +function interruptReasonFor( + result: Extract, +): TurnInterruptReason { + if (result.type === 'cancelled') { + return isUserCancellation(result.reason) ? 'user_cancelled' : 'aborted'; + } + if (isMaxStepsExceededError(result.error)) return 'max_steps'; + if (isError2(result.error) && result.error.code === ErrorCodes.PROVIDER_FILTERED) { + return 'filtered'; + } + return 'error'; +} + +registerScopedService( + LifecycleScope.Agent, + IAgentLoopService, + AgentLoopService, + ScopeActivation.OnScopeCreated, + 'loop', +); diff --git a/packages/agent-core-v2/src/agent/loop/machine/engine.ts b/packages/agent-core-v2/src/agent/loop/machine/engine.ts new file mode 100644 index 0000000000000000000000000000000000000000..590ff8adaac00eddcb1d820173c0919eb06280f3 --- /dev/null +++ b/packages/agent-core-v2/src/agent/loop/machine/engine.ts @@ -0,0 +1,625 @@ +import type { IAgentLLMRequesterService, AgentLLMRequestFinish, AgentLLMRequestSource } from '#/agent/llmRequester/llmRequester'; +import type { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; +import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; +import type { ModelRequestTiming } from '#/llm-adapter/model/model-requester'; +import type { ToolInfo, ToolResult as AgentToolResult, ToolUpdate as AgentToolUpdate } from '#/tool/toolContract'; +import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; +import { createAgentMachine, type PromptGate, type PromptGateVerdict } from '#human/agent/machine'; +import { createTurnMachine, type AssistantEntry, type HistoryMessage, type SystemEntry, type UserEntry } from '#human/agent/turn'; +import { messageAppended, turnEnded } from '#human/agent/events'; +import { agentSlices, type AgentEventStore } from '#human/agent/slices'; +import { credentialsRecovery } from '#human/credentials/credentials'; +import { createEventStoreSync } from '#human/eventStore/eventStore'; +import type { ExternalEvent } from '#human/eventStore/events'; +import { memoryJournal, type SyncStoreJournal } from '#human/eventStore/journal'; +import type { LlmErrorMessage } from '#human/llm/errors'; +import type { FinishInfo } from '#human/llm/finish-reason'; +import type { StreamedMessagePart } from '#human/llm/message'; +import { UNKNOWN_CAPABILITY } from '#human/llm/capability'; +import type { LlmModel } from '#human/llm/model'; +import type { LlmRecovery, LlmRecoveryRecord } from '#human/llm/requester/recovery'; +import type { LlmCredentialProvider, LlmRequestConfig } from '#human/llm/requester/requester'; +import { resolveMaxAttempts } from '#human/llm/requester/retry'; +import type { ToolResult as MachineToolResult, ToolUpdate } from '#human/tool/executor'; +import { createToolMachine } from '#human/tool/machine'; +import type { ToolDefinition } from '#human/tool/tool'; +import { emptyUsage, type TokenUsage } from '#human/llm/usage'; +import type { Actor, Subscription } from '#human/xstate2'; + +import { createMachineRequester, type MachineRequester, type MachineRequesterGateDecision } from './requester'; +import { createMachineTools, type MachineTools, type ToolResultExtras } from './tools'; +import { seededStoreJournal } from './storeJournal'; + +export type { PromptGateVerdict }; + +export type MachineEngineDelta = + | { readonly kind: 'assistant'; readonly delta: string } + | { + readonly kind: 'thinking'; + readonly delta: string; + readonly encrypted?: string; + readonly detailsIndex?: number; + readonly hidden?: boolean; + } + | { + readonly kind: 'toolCall'; + readonly toolCallId: string; + readonly name: string; + readonly argumentsPart?: string; + readonly started?: boolean; + }; + +export type MachineTurnOutcome = 'done' | 'failed' | 'aborted'; + +export type MachineEngineEvent = + | { readonly type: 'turnStarted'; readonly machineTurnId: number; readonly queueItemId?: string; readonly entry?: UserEntry } + | { + readonly type: 'turnSettled'; + readonly outcome: MachineTurnOutcome; + readonly error?: unknown; + readonly produced: readonly HistoryMessage[]; + } + | { readonly type: 'stepStarted'; readonly step: number; readonly recovery?: LlmRecoveryRecord } + | { + readonly type: 'stepCompleted'; + readonly step: number; + readonly entry: AssistantEntry; + readonly usage: TokenUsage; + readonly finish?: FinishInfo; + readonly messageId?: string; + readonly model?: string; + readonly timing?: ModelRequestTiming; + readonly traceId?: string; + } + | { readonly type: 'stepFailed'; readonly step: number; readonly error: LlmErrorMessage; readonly rawError?: unknown } + | { readonly type: 'delta'; readonly delta: MachineEngineDelta } + | { + readonly type: 'retrying'; + readonly step: number; + readonly failedAttempt: number; + readonly nextAttempt: number; + readonly maxAttempts: number; + readonly delayMs: number; + readonly errorName: string; + readonly errorMessage: string; + readonly statusCode?: number; + readonly rawError?: unknown; + } + | { + readonly type: 'recovering'; + readonly step: number; + readonly strategy: string; + readonly action: string; + readonly errorName: string; + readonly errorMessage: string; + readonly statusCode?: number; + } + | { + readonly type: 'toolStarted'; + readonly toolCallId: string; + readonly name: string; + readonly args: unknown; + readonly display?: ToolInputDisplay; + } + | { readonly type: 'toolUpdate'; readonly toolCallId: string; readonly update: ToolUpdate } + | { readonly type: 'toolAsync'; readonly toolCallId: string; readonly text: string } + | { readonly type: 'toolDone'; readonly toolCallId: string; readonly result: MachineToolResult } + | { readonly type: 'toolFailed'; readonly toolCallId: string; readonly error: unknown } + | { readonly type: 'toolAborted'; readonly toolCallId: string } + | { readonly type: 'toolBatchFailed'; readonly error: unknown } + | { readonly type: 'remindersConsumed'; readonly reminders: HistoryMessage[] } + | { readonly type: 'promptBlocked'; readonly queueItemId?: string; readonly entry?: UserEntry } + | { readonly type: 'promptGateFailed'; readonly queueItemId?: string; readonly error: unknown; readonly entry?: UserEntry } + | { readonly type: 'promptSteered'; readonly queueItemIds: readonly string[]; readonly entries: readonly UserEntry[] } + | { readonly type: 'aborting' }; + +export interface CreateMachineEngineOptions { + readonly model: LlmModel; + readonly systemPrompt?: string; + readonly llmRequester: IAgentLLMRequesterService; + readonly toolExecutor: IAgentToolExecutorService; + readonly toolInfos: () => readonly ToolInfo[]; + readonly maxAttemptsPerStep?: number; + readonly recovery?: LlmRecovery; + readonly abortTimeoutMs?: number; + readonly initialTurnId?: number; + readonly journal?: SyncStoreJournal; + readonly trace?: () => LLMRequestTrace | undefined; + readonly source?: () => AgentLLMRequestSource | undefined; + readonly toolTurnId?: () => number | undefined; + readonly steerSignal?: () => AbortSignal | undefined; + readonly gate?: (signal: AbortSignal) => Promise; + readonly promptGate?: PromptGate; + readonly onTrace?: (trace: LLMRequestTrace) => void; + readonly onEvent?: (event: MachineEngineEvent) => void; + readonly onToolResult?: (toolCallId: string, result: AgentToolResult) => void; +} + +export interface MachineEngineRetrySnapshot { + readonly failedAttempt: number; + readonly nextAttempt: number; + readonly maxAttempts: number; + readonly delayMs: number; + readonly errorName?: string; + readonly statusCode?: number; +} + +export interface MachineEngineToolCallSnapshot { + readonly toolCallId: string; + readonly name: string; +} + +export interface MachineEngineTurnSnapshot { + readonly turnId: number; + readonly phase: 'running' | 'tool_call' | 'retrying'; + readonly step: number; + readonly retry?: MachineEngineRetrySnapshot; + readonly activeToolCalls: readonly MachineEngineToolCallSnapshot[]; +} + +export interface MachineEngineSnapshot { + readonly running: boolean; + readonly aborting: boolean; + readonly waitingForBackground: boolean; + readonly paused: boolean; + readonly queue: readonly UserEntry[]; + readonly queueLength: number; + readonly queueIds: readonly (string | undefined)[]; + readonly notificationCount: number; + readonly reminderCount: number; + readonly backgroundCount: number; + readonly turn?: MachineEngineTurnSnapshot; +} + +export interface MachineEngine { + submit(entry: UserEntry): void; + steer(id: string | readonly string[]): void; + notify(entry: UserEntry): void; + remind(key: string, entry: SystemEntry | UserEntry): void; + cancelQueueItem(id: string): void; + abort(reason?: unknown): void; + pause(): void; + resume(): void; + resetHistory(history: readonly HistoryMessage[]): Promise; + resetJournal(journal: SyncStoreJournal): Promise; + stop(): void; + snapshot(): MachineEngineSnapshot; + currentStep(): number; + lastFinish(): AgentLLMRequestFinish | undefined; + readonly toolExtras: ReadonlyMap; + handleToolProgress(toolCallId: string, update: AgentToolUpdate): void; +} + +interface TurnSnapshotLike { + readonly value: unknown; + readonly context: { + readonly steps: number; + readonly attempt: number; + readonly delayMs: number; + readonly pendingToolCalls: readonly { readonly id: string; readonly name: string }[]; + readonly outcomes: Record; + }; +} + +interface MachineSnapshotLike { + readonly value: unknown; + readonly children: Record; + readonly context: { + readonly turnId: number; + readonly queue: readonly UserEntry[]; + readonly notifications: readonly unknown[]; + readonly reminders: readonly unknown[]; + readonly background: Record; + readonly paused: boolean; + }; +} + +function createDeltaSplitter(): (part: StreamedMessagePart) => MachineEngineDelta | undefined { + const callsByIndex = new Map(); + return (part) => { + switch (part.type) { + case 'text': + return { kind: 'assistant', delta: part.text }; + case 'think': + return { + kind: 'thinking', + delta: part.think, + encrypted: part.encrypted, + detailsIndex: part.detailsIndex, + hidden: part.hidden, + }; + case 'image_url': + case 'audio_url': + case 'video_url': + return undefined; + case 'function': { + callsByIndex.set(part._streamIndex, { id: part.id, name: part.name }); + return { + kind: 'toolCall', + toolCallId: part.id, + name: part.name, + argumentsPart: part.arguments ?? undefined, + started: true, + }; + } + case 'tool_call_part': { + if (part.argumentsPart === null) return undefined; + const call = callsByIndex.get(part.index); + if (call === undefined) return undefined; + return { + kind: 'toolCall', + toolCallId: call.id, + name: call.name, + argumentsPart: part.argumentsPart, + }; + } + } + }; +} + +export type MachineEngineAttachRef = Pick< + Actor>, + 'on' | 'send' | 'getSnapshot' +>; + +export const MACHINE_LOOP_MODEL: LlmModel = { + provider: 'agent-loop', + model: 'agent-loop', + capability: UNKNOWN_CAPABILITY, +}; + +export interface MachineEngineAttachBundle { + readonly store: AgentEventStore; + readonly turnLogic: ReturnType; + readonly toolLogic: ReturnType; + readonly tools: ToolDefinition[]; + readonly request: LlmRequestConfig; + readonly requester: MachineRequester; + readonly machineTools: MachineTools; + readonly promptGate?: PromptGate; +} + +export function machineEngineAttachBundle(options: CreateMachineEngineOptions): MachineEngineAttachBundle { + const publish = (event: MachineEngineEvent): void => { + options.onEvent?.(event); + }; + const requester = createMachineRequester(options.llmRequester, { + source: options.source, + gate: options.gate, + onTrace: options.onTrace, + }); + const tools = createMachineTools({ + toolExecutor: options.toolExecutor, + toolInfos: options.toolInfos, + turnId: () => options.toolTurnId?.() ?? 0, + steerSignal: options.steerSignal, + trace: options.trace, + onToolCall: (payload) => { + publish({ + type: 'toolStarted', + toolCallId: payload.toolCallId, + name: payload.name, + args: payload.args, + display: payload.display, + }); + }, + onToolResult: options.onToolResult, + onBatchError: (error) => { + publish({ type: 'toolBatchFailed', error }); + }, + }); + const current = (): LlmCredentialProvider | undefined => { + const source = options.source?.(); + return source?.type === 'turn' + ? options.llmRequester.credentialProviderForTurn(source.turnId) + : options.llmRequester.currentCredentialProvider(); + }; + const credentialProvider: LlmCredentialProvider = { + resolve: () => current()?.resolve(), + canRecover: (error) => current()?.canRecover?.(error) === true, + invalidate: () => current()?.invalidate?.(), + }; + const baseJournal = options.journal; + const initialTurnId = options.initialTurnId ?? 0; + const journal = engineJournal(baseJournal, initialTurnId); + const store: AgentEventStore = createEventStoreSync({ journal, slices: agentSlices }); + tools.sync(); + return { + store, + turnLogic: createTurnMachine(requester.requester, { + retry: { maxAttemptsPerStep: options.maxAttemptsPerStep }, + recovery: { + propose: (ctx) => credentialsRecovery.propose(ctx) ?? options.recovery?.propose(ctx), + }, + }), + toolLogic: createToolMachine(tools.executor), + tools: tools.tools, + request: { model: options.model, systemPrompt: options.systemPrompt, credentialProvider }, + requester, + machineTools: tools, + promptGate: options.promptGate, + }; +} + +export function attachMachineEngine( + ref: MachineEngineAttachRef, + bundle: MachineEngineAttachBundle, + options: CreateMachineEngineOptions, +): MachineEngine { + let currentStep = 0; + let split = createDeltaSplitter(); + let pendingFailure: { step: number; error: LlmErrorMessage } | undefined; + let lastRetry: MachineEngineRetrySnapshot | undefined; + + const publish = (event: MachineEngineEvent): void => { + options.onEvent?.(event); + }; + const store = bundle.store; + const requester = bundle.requester; + const tools = bundle.machineTools; + let currentJournal = options.journal; + const subscriptions: Subscription[] = [ + ref.on('turn.started', (event) => { + currentStep = 0; + split = createDeltaSplitter(); + pendingFailure = undefined; + lastRetry = undefined; + publish({ type: 'turnStarted', machineTurnId: event.turnId, queueItemId: event.queueItemId, entry: event.entry }); + }), + ref.on('step.started', (event) => { + currentStep = event.step; + }), + ref.on('llm.sent', (event) => { + split = createDeltaSplitter(); + lastRetry = undefined; + tools.beginBatch(); + publish({ type: 'stepStarted', step: currentStep, recovery: event.recovery }); + }), + ref.on('llm.streaming.part', (event) => { + const delta = split(event.part); + if (delta !== undefined) publish({ type: 'delta', delta }); + }), + ref.on('llm.retrying', (event) => { + pendingFailure = undefined; + lastRetry = { + failedAttempt: event.failedAttempt, + nextAttempt: event.nextAttempt, + maxAttempts: event.maxAttempts, + delayMs: event.delayMs, + errorName: event.errorName, + statusCode: event.statusCode, + }; + publish({ + type: 'retrying', + step: currentStep, + failedAttempt: event.failedAttempt, + nextAttempt: event.nextAttempt, + maxAttempts: event.maxAttempts, + delayMs: event.delayMs, + errorName: event.errorName, + errorMessage: event.errorMessage, + statusCode: event.statusCode, + rawError: requester.lastError(), + }); + }), + ref.on('llm.recovering', (event) => { + pendingFailure = undefined; + publish({ + type: 'recovering', + step: currentStep, + strategy: event.strategy, + action: event.action, + errorName: event.errorName, + errorMessage: event.errorMessage, + statusCode: event.statusCode, + }); + }), + ref.on('llm.done', (event) => { + pendingFailure = undefined; + lastRetry = undefined; + tools.beginBatch(event.entry.message.toolCalls); + const finish = requester.lastFinish(); + const meta = event.entry.meta; + publish({ + type: 'stepCompleted', + step: currentStep, + entry: event.entry, + usage: finish?.usage ?? meta?.usage ?? emptyUsage(), + finish: + finish !== undefined + ? { + finishReason: finish.providerFinishReason ?? null, + rawFinishReason: finish.rawFinishReason ?? null, + } + : meta?.finish, + messageId: finish?.providerMessageId ?? meta?.messageId, + model: finish?.model ?? meta?.model?.model, + timing: finish?.timing, + traceId: finish?.traceId, + }); + }), + ref.on('llm.failed.syntax', (event) => { + pendingFailure = { step: currentStep, error: event.error }; + }), + ref.on('llm.failed.remote', (event) => { + pendingFailure = { step: currentStep, error: event.error }; + }), + ref.on('tool.update', (event) => { + publish({ type: 'toolUpdate', toolCallId: event.toolCallId, update: event.update }); + }), + ref.on('tool.detached', (event) => { + publish({ type: 'toolAsync', toolCallId: event.toolCallId, text: event.text }); + }), + ref.on('tool.done', (event) => { + publish({ type: 'toolDone', toolCallId: event.toolCallId, result: event.result }); + }), + ref.on('tool.failed', (event) => { + publish({ type: 'toolFailed', toolCallId: event.toolCallId, error: event.error }); + }), + ref.on('tool.aborted', (event) => { + publish({ type: 'toolAborted', toolCallId: event.toolCallId }); + }), + ref.on('turn.reminders_consumed', (event) => { + publish({ type: 'remindersConsumed', reminders: event.reminders }); + }), + ref.on('prompt.blocked', (event) => { + publish({ type: 'promptBlocked', queueItemId: event.queueItemId, entry: event.entry }); + }), + ref.on('prompt.gate_failed', (event) => { + publish({ type: 'promptGateFailed', queueItemId: event.queueItemId, error: event.error, entry: event.entry }); + }), + ref.on('prompt.steered', (event) => { + publish({ type: 'promptSteered', queueItemIds: event.queueItemIds, entries: event.entries }); + }), + ref.on('turn.aborting', () => { + publish({ type: 'aborting' }); + }), + ref.on('turn.done', (event) => { + publish({ type: 'turnSettled', outcome: 'done', produced: event.messages }); + }), + ref.on('turn.failed', (event) => { + const failure = pendingFailure; + if (failure !== undefined) { + publish({ + type: 'stepFailed', + step: failure.step, + error: failure.error, + rawError: requester.lastError(), + }); + } + publish({ + type: 'turnSettled', + outcome: 'failed', + error: event.error, + produced: event.messages, + }); + }), + ref.on('turn.aborted', (event) => { + publish({ type: 'turnSettled', outcome: 'aborted', produced: event.messages }); + }), + ]; + + return { + submit: (entry) => { + tools.sync(); + ref.send({ type: 'input.submit', entry }); + }, + steer: (id) => { + tools.sync(); + ref.send({ type: 'input.steer', id }); + }, + notify: (entry) => { + tools.sync(); + ref.send({ type: 'input.notify', entry }); + }, + remind: (key, entry) => { + tools.sync(); + ref.send({ type: 'input.remind', key, entry }); + }, + cancelQueueItem: (id) => { + ref.send({ type: 'input.cancel', id }); + }, + abort: (reason) => { + ref.send({ type: 'input.abort', reason }); + }, + pause: () => { + ref.send({ type: 'input.pause' }); + }, + resume: () => { + ref.send({ type: 'input.continue' }); + }, + resetHistory: (history) => { + const events: ExternalEvent[] = history.map((message) => messageAppended({ message })); + const nextTurnId = (ref.getSnapshot() as unknown as MachineSnapshotLike).context.turnId; + if (nextTurnId > 0) { + events.push(turnEnded({ turnId: nextTurnId - 1, outcome: 'done' })); + } + const seed = seedRecords(events); + const next = currentJournal === undefined ? seed : seededStoreJournal(currentJournal, seed.readSync()); + return store.reset(next); + }, + resetJournal: (journal) => { + currentJournal = journal; + return store.reset(journal); + }, + stop: () => { + for (const subscription of subscriptions) subscription.unsubscribe(); + }, + snapshot: () => { + const snapshot = ref.getSnapshot() as unknown as MachineSnapshotLike; + const value = snapshot.value; + const turnRef = snapshot.children['turn']; + let turn: MachineEngineTurnSnapshot | undefined; + if (turnRef !== undefined) { + const turnSnapshot = turnRef.getSnapshot(); + const turnValue = turnSnapshot.value; + const phase = + turnValue === 'retrying' + ? ('retrying' as const) + : typeof turnValue === 'object' && turnValue !== null && 'acting' in turnValue + ? ('tool_call' as const) + : ('running' as const); + const context = turnSnapshot.context; + turn = { + turnId: snapshot.context.turnId, + phase, + step: context.steps, + retry: + phase === 'retrying' + ? (lastRetry ?? { + failedAttempt: context.attempt - 1, + nextAttempt: context.attempt, + maxAttempts: resolveMaxAttempts({ maxAttemptsPerStep: options.maxAttemptsPerStep }), + delayMs: context.delayMs, + }) + : undefined, + activeToolCalls: context.pendingToolCalls + .filter((toolCall) => context.outcomes[toolCall.id] === undefined) + .map((toolCall) => ({ toolCallId: toolCall.id, name: toolCall.name })), + }; + } + return { + running: value === 'running' || (typeof value === 'object' && value !== null && 'running' in value), + aborting: typeof value === 'object' && value !== null && 'running' in value && + (value as { running?: unknown }).running === 'aborting', + waitingForBackground: + typeof value === 'object' && value !== null && 'idle' in value && + (value as { idle?: unknown }).idle === 'waiting', + paused: snapshot.context.paused, + queue: snapshot.context.queue, + queueLength: snapshot.context.queue.length, + queueIds: snapshot.context.queue.map((entry) => entry.meta?.promptId), + notificationCount: snapshot.context.notifications.length, + reminderCount: snapshot.context.reminders.length, + backgroundCount: Object.keys(snapshot.context.background).length, + turn, + }; + }, + lastFinish: () => requester.lastFinish(), + currentStep: () => currentStep, + toolExtras: tools.extras, + handleToolProgress: (toolCallId, update) => { + tools.handleProgress(toolCallId, update); + }, + }; +} + +function seedRecords(events: readonly ExternalEvent[]): SyncStoreJournal { + const journal = memoryJournal(); + for (const event of events) { + void journal.append({ type: event.type, kind: 'event', data: event }); + } + return journal; +} + +export function engineJournal(base: SyncStoreJournal | undefined, initialTurnId: number): SyncStoreJournal { + if (base === undefined) { + if (initialTurnId <= 0) return memoryJournal(); + return seedRecords([turnEnded({ turnId: initialTurnId - 1, outcome: 'done' })]); + } + if (initialTurnId <= 0 || base.readSync().length > 0) return base; + return seededStoreJournal( + base, + seedRecords([turnEnded({ turnId: initialTurnId - 1, outcome: 'done' })]).readSync(), + ); +} diff --git a/packages/agent-core-v2/src/agent/loop/machine/history.ts b/packages/agent-core-v2/src/agent/loop/machine/history.ts new file mode 100644 index 0000000000000000000000000000000000000000..8fb5653991bc8d61109459db5411f248f53c5b82 --- /dev/null +++ b/packages/agent-core-v2/src/agent/loop/machine/history.ts @@ -0,0 +1,25 @@ +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { toLlmMessage } from '#/llm-adapter/contract/message'; +import type { HistoryMessage } from '#human/agent/turn'; +import type { UserMessage } from '#human/llm/message'; +import { emptyUsage } from '#human/llm/usage'; + +export const EMPTY_MACHINE_PROMPT: UserMessage = { role: 'user', content: [] }; + +export function historyEntryFromContext(message: ContextMessage): HistoryMessage { + const converted = toLlmMessage(message); + switch (converted.role) { + case 'system': + return { message: converted, meta: {} }; + case 'user': + return { message: converted, meta: {} }; + case 'assistant': + return { message: converted, meta: { usage: emptyUsage() } }; + case 'tool': + return { message: converted, meta: {} }; + } +} + +export function historyFromContext(messages: readonly ContextMessage[]): HistoryMessage[] { + return messages.map(historyEntryFromContext); +} diff --git a/packages/agent-core-v2/src/agent/loop/machine/index.ts b/packages/agent-core-v2/src/agent/loop/machine/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..04867c9c04126362b3fc74903ffcb6b740724e7a --- /dev/null +++ b/packages/agent-core-v2/src/agent/loop/machine/index.ts @@ -0,0 +1,5 @@ +export * from './engine'; +export * from './history'; +export * from './requester'; +export * from './storeJournal'; +export * from './tools'; diff --git a/packages/agent-core-v2/src/agent/loop/machine/requester.ts b/packages/agent-core-v2/src/agent/loop/machine/requester.ts new file mode 100644 index 0000000000000000000000000000000000000000..da374a41977a851671bec4b750d6851cbf14b08d --- /dev/null +++ b/packages/agent-core-v2/src/agent/loop/machine/requester.ts @@ -0,0 +1,121 @@ +import type { + AgentLLMRequestFinish, + AgentLLMRequestSource, + IAgentLLMRequesterService, +} from '#/agent/llmRequester/llmRequester'; +import { unwrapErrorCause } from '#/errors'; +import { llmMessageFromError } from '#/llm-adapter/contract/errors'; +import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; +import { toLlmErrorMessage, type LlmRemoteErrorMessage } from '#human/llm/errors'; +import type { + LlmRequestConfig, + LlmRequestContent, + LlmRequestControl, + LlmRequester, +} from '#human/llm/requester/requester'; + +export type MachineRequesterGateDecision = + | { readonly type: 'proceed'; readonly signal?: AbortSignal; readonly step?: number } + | { readonly type: 'fail' }; + +export interface MachineRequesterOptions { + readonly source?: () => AgentLLMRequestSource | undefined; + readonly gate?: (signal: AbortSignal) => Promise; + readonly onTrace?: (trace: LLMRequestTrace) => void; +} + +export interface MachineRequester { + readonly requester: LlmRequester; + lastFinish(): AgentLLMRequestFinish | undefined; + lastError(): unknown; +} + +const GATE_FAILURE: LlmRemoteErrorMessage = { + kind: 'abort', + message: 'The agent loop gate stopped the step.', +}; + +function toRemoteErrorMessage(error: unknown, signal: AbortSignal): LlmRemoteErrorMessage { + const raw = unwrapErrorCause(error); + const known = + llmMessageFromError(raw) ?? (raw === error ? undefined : llmMessageFromError(error)); + if (known !== undefined) return known; + if (signal.aborted) { + return { kind: 'abort', message: 'The operation was aborted.' }; + } + return toLlmErrorMessage(raw); +} + +export function createMachineRequester( + service: IAgentLLMRequesterService, + options?: MachineRequesterOptions, +): MachineRequester { + let lastFinish: AgentLLMRequestFinish | undefined; + let lastError: unknown; + const generate = async ( + _config: LlmRequestConfig, + _content: LlmRequestContent, + control: LlmRequestControl, + ): Promise => { + const decision = + options?.gate !== undefined + ? await options.gate(control.signal) + : ({ type: 'proceed' } as const); + if (decision.type === 'fail') { + control.onEvent?.({ type: 'llm.failed.remote', error: GATE_FAILURE }); + return; + } + if (control.signal.aborted) { + control.onEvent?.({ type: 'llm.failed.remote', error: GATE_FAILURE }); + return; + } + const signal = + decision.signal !== undefined + ? AbortSignal.any([control.signal, decision.signal]) + : control.signal; + lastError = undefined; + control.onEvent?.({ type: 'llm.sent' }); + const baseSource = options?.source?.(); + const source: AgentLLMRequestSource | undefined = + baseSource?.type === 'turn' && decision.step !== undefined + ? { ...baseSource, step: decision.step } + : baseSource; + const task = service.start( + { + source, + onAttemptRetry: () => control.onEvent?.({ type: 'llm.request.retrying' }), + }, + (part) => control.onEvent?.({ type: 'llm.streaming.part', part }), + signal, + ); + options?.onTrace?.(task.trace); + try { + const finish = await task.result; + lastFinish = finish; + control.onEvent?.({ type: 'llm.streaming.usage', usage: finish.usage }); + control.onEvent?.({ + type: 'llm.streaming.finish', + finish: { + finishReason: finish.providerFinishReason ?? null, + rawFinishReason: finish.rawFinishReason ?? null, + }, + }); + if (finish.providerMessageId !== undefined) { + control.onEvent?.({ type: 'llm.streaming.message_id', messageId: finish.providerMessageId }); + } + control.onEvent?.({ type: 'llm.done' }); + } catch (error) { + lastFinish = undefined; + lastError = error; + control.onEvent?.({ + type: 'llm.failed.remote', + error: toRemoteErrorMessage(error, signal), + }); + } + }; + return { + requester: { generate }, + lastFinish: () => lastFinish, + lastError: () => lastError, + }; +} diff --git a/packages/agent-core-v2/src/agent/loop/machine/storeJournal.ts b/packages/agent-core-v2/src/agent/loop/machine/storeJournal.ts new file mode 100644 index 0000000000000000000000000000000000000000..cdac16ba5fe1fadd8c4a8134e5785590463f79c4 --- /dev/null +++ b/packages/agent-core-v2/src/agent/loop/machine/storeJournal.ts @@ -0,0 +1,88 @@ +import { HUMAN_AGENT_DOMAIN, humanEventType, humanRecordType } from '#/wire/human'; +import type { WireLine } from '#/wire/tree/index'; +import type { IWireService } from '#/wire/wire'; +import type { JournalRecord, SyncStoreJournal } from '#human/eventStore/journal'; +import type { AppendInput, EntryLine } from '#human/store/types'; + +export const ENGINE_JOURNAL_DOMAIN = HUMAN_AGENT_DOMAIN; + +function toJournalRecord(line: WireLine, domain: string, branch: string, seq: number): JournalRecord | undefined { + const type = humanEventType(line.record.type, domain); + if (type === undefined) return undefined; + const ts = typeof line.record.time === 'number' ? line.record.time : 0; + const kind = typeof line.record['kind'] === 'string' ? line.record['kind'] : 'event'; + return { branch, seq, ts, type, kind, data: { ...line.record, type } }; +} + +function toEntryLine(input: AppendInput, seq: number): EntryLine { + const data = input.data ?? null; + return { + kind: 'entry', + seq, + ts: Date.now(), + type: input.type, + payload: { kind: input.kind, size: JSON.stringify(data).length, data }, + }; +} + +export function wireStoreJournal(wire: IWireService, domain: string): SyncStoreJournal { + let records: JournalRecord[] | undefined; + const read = (): JournalRecord[] => { + if (records === undefined) { + records = []; + const branch = wire.journalRef.branch; + for (const line of wire.readHumanChain()) { + const record = toJournalRecord(line, domain, branch, records.length); + if (record !== undefined) records.push(record); + } + } + return records; + }; + return { + get ref() { + return { tree: wire.journalRef.tree, branch: wire.journalRef.branch }; + }, + append: (input) => { + wire.append({ ...(input.data as Record), type: humanRecordType(domain, input.type), kind: input.kind }); + const journalRecord: JournalRecord = { + branch: wire.journalRef.branch, + seq: read().length, + ts: Date.now(), + type: input.type, + kind: input.kind, + data: input.data ?? null, + }; + read().push(journalRecord); + return Promise.resolve(toEntryLine(input, journalRecord.seq)); + }, + read: async function* () { + for (const record of read()) yield record; + }, + readSync: () => [...read()], + nextSeq: () => read().length, + settled: () => wire.settled(), + }; +} + +export function seededStoreJournal( + base: SyncStoreJournal, + seed: readonly JournalRecord[], +): SyncStoreJournal { + let appended = 0; + return { + get ref() { + return base.ref; + }, + append: async (input) => { + const entry = await base.append(input); + appended += 1; + return entry; + }, + read: async function* () { + for (const record of seed) yield record; + }, + readSync: () => [...seed], + nextSeq: () => seed.length + appended, + settled: () => base.settled(), + }; +} diff --git a/packages/agent-core-v2/src/agent/loop/machine/tools.ts b/packages/agent-core-v2/src/agent/loop/machine/tools.ts new file mode 100644 index 0000000000000000000000000000000000000000..c05ad3e40d8df206238dca0d039bfbd9300c91b7 --- /dev/null +++ b/packages/agent-core-v2/src/agent/loop/machine/tools.ts @@ -0,0 +1,283 @@ +import type { + IAgentToolExecutorService, + ToolCallStartedPayload, + ToolExecutionResult, +} from '#/agent/toolExecutor/toolExecutor'; +import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; +import { toErrorMessage } from '#/_base/errors/errorMessage'; +import type { + ToolDelivery, + ToolInfo, + ToolResult as AgentToolResult, + ToolUpdate as AgentToolUpdate, +} from '#/tool/toolContract'; +import type { ContentPart, ToolCall } from '#human/llm/message'; +import type { ToolExecuteInput, ToolExecutor, ToolResult, ToolUpdate } from '#human/tool/executor'; +import type { ToolDefinition } from '#human/tool/tool'; + +const EMPTY_TOOL_PARAMETERS: Record = { + type: 'object', + properties: {}, +}; + +export interface ToolResultExtras { + readonly stopTurn?: boolean; + readonly stopTurnReason?: string; + readonly note?: string; + readonly delivery?: ToolDelivery; + readonly stopBatchAfterThis?: boolean; + readonly output?: string | ContentPart[]; + readonly isError?: boolean; +} + +export interface CreateMachineToolsOptions { + readonly toolExecutor: IAgentToolExecutorService; + readonly toolInfos: () => readonly ToolInfo[]; + readonly turnId: () => number; + readonly steerSignal?: () => AbortSignal | undefined; + readonly trace?: () => LLMRequestTrace | undefined; + readonly onToolCall?: (payload: ToolCallStartedPayload) => void; + readonly onToolResult?: (toolCallId: string, result: AgentToolResult) => void; + readonly onBatchError?: (error: unknown) => void; +} + +export interface MachineTools { + readonly tools: ToolDefinition[]; + readonly executor: ToolExecutor; + readonly extras: ReadonlyMap; + sync(): void; + beginBatch(expectedCalls?: readonly ToolCall[]): void; + handleProgress(toolCallId: string, update: AgentToolUpdate): void; +} + +interface PendingEntry { + readonly input: ToolExecuteInput; + readonly resolve: (result: ToolResult) => void; + readonly removeAbortListener: () => void; +} + +function toContentParts(output: string | ContentPart[]): ContentPart[] { + return typeof output === 'string' ? [{ type: 'text', text: output }] : output; +} + +export function createMachineTools(options: CreateMachineToolsOptions): MachineTools { + const extras = new Map(); + const progressHandlers = new Map void) | undefined>(); + const definitions = new Map(); + const tools: ToolDefinition[] = []; + const pending = new Map(); + let expectedIds: readonly string[] | undefined; + let batchInFlight = false; + + const materialize = (): void => { + for (const info of options.toolInfos()) { + if (definitions.has(info.name)) continue; + const definition: ToolDefinition = { + name: info.name, + description: info.description, + parameters: info.parameters ?? EMPTY_TOOL_PARAMETERS, + deferred: info.disclosure === 'deferred' ? true : undefined, + execute, + }; + definitions.set(info.name, definition); + tools.push(definition); + } + }; + + const settleEntry = (entry: PendingEntry, result: ToolResult): void => { + entry.removeAbortListener(); + progressHandlers.delete(entry.input.toolCall.id); + entry.resolve(result); + }; + + const settleAborted = (entry: PendingEntry): void => { + settleEntry(entry, { + content: [{ type: 'text', text: `Tool "${entry.input.toolCall.name}" aborted before execution.` }], + isError: true, + }); + }; + + const runBatch = async (entries: readonly PendingEntry[]): Promise => { + batchInFlight = true; + const inFlight = new Map(); + const settleRemaining = (error?: unknown): void => { + for (const entry of inFlight.values()) { + settleEntry(entry, { + content: [ + { + type: 'text', + text: + error === undefined + ? `Tool "${entry.input.toolCall.name}" produced no result.` + : `Tool "${entry.input.toolCall.name}" failed: ${toErrorMessage(error)}`, + }, + ], + isError: true, + }); + } + inFlight.clear(); + }; + try { + for (const entry of entries) inFlight.set(entry.input.toolCall.id, entry); + const signal = AbortSignal.any(entries.map((entry) => entry.input.signal)); + const calls = entries.map((entry) => entry.input.toolCall); + const stream = options.toolExecutor.execute(calls, { + signal, + steerSignal: options.steerSignal?.(), + turnId: options.turnId(), + trace: options.trace?.(), + onToolCall: options.onToolCall, + }); + for await (const result of stream) { + const entry = inFlight.get(result.toolCallId); + if (entry === undefined) continue; + inFlight.delete(result.toolCallId); + try { + applyResult(entry, result); + } catch (error) { + settleEntry(entry, { + content: [ + { + type: 'text', + text: `Tool "${entry.input.toolCall.name}" failed: ${toErrorMessage(error)}`, + }, + ], + isError: true, + }); + } + } + settleRemaining(); + } catch (error) { + try { + options.onBatchError?.(error); + } finally { + settleRemaining(error); + } + } finally { + batchInFlight = false; + } + }; + + const startBatch = (entries: readonly PendingEntry[]): void => { + void runBatch(entries).catch((error: unknown) => { + options.onBatchError?.(error); + }); + }; + + const applyResult = (entry: PendingEntry, matched: ToolExecutionResult): void => { + const id = entry.input.toolCall.id; + const { result } = matched; + options.onToolResult?.(id, result); + extras.set(id, { + stopTurn: result.stopTurn, + stopTurnReason: result.stopTurnReason, + note: result.note, + delivery: result.delivery, + stopBatchAfterThis: result.stopBatchAfterThis, + output: result.output, + isError: result.isError, + }); + settleEntry(entry, { + content: toContentParts(result.output), + isError: result.isError === true ? true : undefined, + }); + }; + + const flushIfReady = (): void => { + if (expectedIds === undefined || batchInFlight) return; + if (!expectedIds.every((id) => pending.has(id))) return; + const entries: PendingEntry[] = []; + for (const id of expectedIds) { + const entry = pending.get(id); + if (entry === undefined) continue; + pending.delete(id); + entries.push(entry); + } + if (entries.length === 0) return; + startBatch(entries); + }; + + const execute = (input: ToolExecuteInput): Promise => { + if (expectedIds === undefined || batchInFlight) { + progressHandlers.set(input.toolCall.id, input.onUpdate); + return new Promise((resolve) => { + const entry: PendingEntry = { input, resolve, removeAbortListener: () => {} }; + startBatch([entry]); + }); + } + return new Promise((resolve) => { + const previous = pending.get(input.toolCall.id); + if (previous !== undefined) { + pending.delete(input.toolCall.id); + settleEntry(previous, { + content: [ + { + type: 'text', + text: `Tool "${previous.input.toolCall.name}" superseded by a duplicate tool call id.`, + }, + ], + isError: true, + }); + } + progressHandlers.set(input.toolCall.id, input.onUpdate); + const onAbort = (): void => { + if (!pending.delete(input.toolCall.id)) return; + const stale = [...pending.values()]; + pending.clear(); + settleAborted({ input, resolve, removeAbortListener: () => {} }); + for (const entry of stale) settleAborted(entry); + }; + input.signal.addEventListener('abort', onAbort, { once: true }); + pending.set(input.toolCall.id, { + input, + resolve, + removeAbortListener: () => { + input.signal.removeEventListener('abort', onAbort); + }, + }); + flushIfReady(); + }); + }; + + return { + tools, + executor: { + execute: async (input) => { + materialize(); + const tool = definitions.get(input.toolCall.name); + if (tool === undefined) { + return { + content: [{ type: 'text', text: `unknown tool: ${input.toolCall.name}` }], + isError: true, + }; + } + return tool.execute(input); + }, + }, + extras, + sync: materialize, + beginBatch: (expectedCalls) => { + materialize(); + if (expectedCalls === undefined) { + expectedIds = undefined; + const stale = [...pending.values()]; + pending.clear(); + for (const entry of stale) settleAborted(entry); + return; + } + expectedIds = [ + ...new Set(expectedCalls.filter((call) => definitions.has(call.name)).map((call) => call.id)), + ]; + flushIfReady(); + }, + handleProgress: (toolCallId, update) => { + const onUpdate = progressHandlers.get(toolCallId); + if (onUpdate === undefined) return; + onUpdate({ + key: update.customKind ?? update.kind, + text: update.text ?? '', + percent: update.percent, + }); + }, + }; +} diff --git a/packages/agent-core-v2/src/agent/loop/promptChannel.ts b/packages/agent-core-v2/src/agent/loop/promptChannel.ts new file mode 100644 index 0000000000000000000000000000000000000000..36d0060cb8422fba8121798b8f019a62db8bcecc --- /dev/null +++ b/packages/agent-core-v2/src/agent/loop/promptChannel.ts @@ -0,0 +1,95 @@ +import { createDecorator } from '#/_base/di/instantiation'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IEventService } from '#/app/event/event'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { promptMetadataTextFromContentParts } from '#/agent/prompt/promptMetadataText'; +import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { applyPromptMetadataUpdate } from '#/session/sessionMetadata/promptMetadata'; + +import { + IAgentLoopService, + type PromptLaunchResult, + type PromptPayload, + type SteerPayload, +} from './loop'; + +export interface IAgentPromptChannel { + readonly _serviceBrand: undefined; + submit(payload: PromptPayload): Promise; + submitSteer(payload: SteerPayload): Promise; +} + +export const IAgentPromptChannel = createDecorator('agentPromptService'); + +export class AgentPromptChannel implements IAgentPromptChannel { + declare readonly _serviceBrand: undefined; + + constructor( + @IAgentLoopService private readonly loop: IAgentLoopService, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @ISessionMetadata private readonly metadata: ISessionMetadata, + @IEventService private readonly eventService: IEventService, + @ISessionContext private readonly sessionContext: ISessionContext, + @ITelemetryService private readonly telemetry: ITelemetryService, + ) {} + + async submit(payload: PromptPayload): Promise { + await this.updateMetadata(promptMetadataTextFromContentParts(payload.input)); + const status = this.loop.snapshot(); + const { id } = this.loop.submit({ + message: { role: 'user', content: [...payload.input] }, + meta: { promptId: payload.promptId, origin: { kind: 'user' }, tracked: true }, + }); + if (status.state === 'running' || status.paused || status.queue.length > 0) return undefined; + return launchResult(this.loop, id); + } + + async submitSteer(payload: SteerPayload): Promise { + this.telemetry.track2('input_steer', { parts: payload.input.length }); + await this.updateMetadata(promptMetadataTextFromContentParts(payload.input)); + const status = this.loop.snapshot(); + const { id } = this.loop.submit( + { + message: { role: 'user', content: [...payload.input] }, + meta: { origin: { kind: 'user' }, tracked: true }, + }, + { steerIfActive: true }, + ); + if (status.state === 'running' && status.activePromptId === undefined) return undefined; + return launchResult(this.loop, id); + } + + private async updateMetadata(text: string | undefined): Promise { + if (this.scopeContext.agentId !== MAIN_AGENT_ID) return; + await applyPromptMetadataUpdate( + { + metadata: this.metadata, + eventService: this.eventService, + sessionId: this.sessionContext.sessionId, + }, + text, + ); + } +} + +async function launchResult( + loop: IAgentLoopService, + id: string, +): Promise { + const turn = await loop.promptHandle(id)?.launched; + if (turn === undefined) return undefined; + await turn.ready.catch(() => undefined); + return turn.id === undefined ? undefined : { turn_id: turn.id }; +} + +registerScopedService( + LifecycleScope.Agent, + IAgentPromptChannel, + AgentPromptChannel, + ScopeActivation.OnDemand, + 'prompt', +); diff --git a/packages/agent-core-v2/src/agent/loop/turnEvents.ts b/packages/agent-core-v2/src/agent/loop/turnEvents.ts new file mode 100644 index 0000000000000000000000000000000000000000..f0163142e58b2e113e37fc7224700e36c7c89aa8 --- /dev/null +++ b/packages/agent-core-v2/src/agent/loop/turnEvents.ts @@ -0,0 +1,266 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { z } from 'zod'; + +import type { PromptOrigin } from '#/agent/contextMemory/types'; +import { parseDaemonFileUrl } from '#/agent/media/mediaRef'; +import { AgentEvent2, registerEvent2Class } from '#/app/event/event2'; +import type { FinishReason } from '#human/llm/finish-reason'; +import type { ContentPart, TextPart } from '#human/llm/message'; +import type { TokenUsage } from '#human/llm/usage'; + +export type TurnEndReason = 'completed' | 'cancelled' | 'failed' | 'blocked'; + +export type TurnInterruptReason = + | 'user_cancelled' + | 'aborted' + | 'max_steps' + | 'error' + | 'filtered' + | 'blocked'; + +export interface TurnPromptAttachmentFile { + readonly kind: 'file'; + readonly name: string; + readonly mediaType: string; + readonly size: number; + readonly path: string; +} + +export type TurnPromptAttachment = + | { readonly kind: 'image' | 'video' | 'audio'; readonly fileId: string; readonly name?: string } + | TurnPromptAttachmentFile; + +export interface TurnStartedPayload { + readonly agentId: string; + readonly turnId: number; + readonly promptId?: string; + readonly origin: PromptOrigin; + readonly prompt?: string; + readonly promptAttachments?: readonly TurnPromptAttachment[]; +} + +export class TurnStarted extends AgentEvent2 { + static override readonly type = 'turn.started'; + static override readonly observable = true; +} +export interface TurnStarted extends TurnStartedPayload {} + +export function turnPromptText( + input: readonly ContentPart[], + origin?: PromptOrigin, +): string | undefined { + const bundledBlocks = origin?.kind === 'user' ? (origin.skillActivations?.length ?? 0) : 0; + const text = input + .filter((part): part is TextPart => part.type === 'text') + .slice(bundledBlocks) + .map((part) => part.text) + .join(''); + return text.length > 0 ? text : undefined; +} + +export function turnPromptAttachments( + input: readonly ContentPart[], + origin?: PromptOrigin, +): TurnStartedPayload['promptAttachments'] { + const attachments: TurnPromptAttachment[] = []; + const promptMediaFileId = (url: string, id: string | undefined): string | undefined => { + const fileId = parseDaemonFileUrl(url)?.fileId; + if (id === undefined) return fileId; + return fileId === id ? id : undefined; + }; + for (const part of input) { + if (part.type === 'image_url') { + const fileId = promptMediaFileId(part.imageUrl.url, part.imageUrl.id); + if (fileId !== undefined) attachments.push({ kind: 'image', fileId, name: part.imageUrl.name }); + } else if (part.type === 'video_url') { + const fileId = promptMediaFileId(part.videoUrl.url, part.videoUrl.id); + if (fileId !== undefined) attachments.push({ kind: 'video', fileId, name: part.videoUrl.name }); + } else if (part.type === 'audio_url') { + const fileId = promptMediaFileId(part.audioUrl.url, part.audioUrl.id); + if (fileId !== undefined) attachments.push({ kind: 'audio', fileId }); + } + } + if (origin?.kind === 'user' || origin?.kind === 'skill_activation') { + for (const attachment of origin.attachments ?? []) { + attachments.push({ kind: 'file', ...attachment }); + } + } + return attachments.length > 0 ? attachments : undefined; +} + +export function isDisplayablePromptOrigin(origin: PromptOrigin): boolean { + if (origin.kind === 'user') return true; + if (origin.kind === 'system_trigger' && origin.name === 'subagent') return true; + return ( + (origin.kind === 'skill_activation' || origin.kind === 'plugin_command') && + origin.trigger === 'user-slash' + ); +} + +export interface TurnStepStartedPayload { + readonly agentId: string; + readonly turnId: number; + readonly step: number; + readonly stepId?: string; +} + +export class TurnStepStarted extends AgentEvent2 { + static override readonly type = 'turn.step.started'; + static override readonly observable = true; +} +export interface TurnStepStarted extends TurnStepStartedPayload {} + +export interface TurnStepCompletedPayload { + readonly agentId: string; + readonly turnId: number; + readonly step: number; + readonly stepId?: string; + readonly usage?: TokenUsage; + readonly finishReason?: string; + readonly llmFirstTokenLatencyMs?: number; + readonly llmStreamDurationMs?: number; + readonly llmRequestBuildMs?: number; + readonly llmServerFirstTokenMs?: number; + readonly llmServerDecodeMs?: number; + readonly llmClientConsumeMs?: number; + readonly llmClientBlockedMs?: number; + readonly providerFinishReason?: FinishReason; + readonly rawFinishReason?: string; +} + +export class TurnStepCompleted extends AgentEvent2 { + static override readonly type = 'turn.step.completed'; + static override readonly observable = true; +} +export interface TurnStepCompleted extends TurnStepCompletedPayload {} + +export interface TurnStepInterruptedPayload { + readonly agentId: string; + readonly turnId: number; + readonly step: number; + readonly stepId?: string; + readonly reason: string; + readonly message?: string; +} + +const turnStepInterruptedSchema = z.object({ + agentId: z.string(), + turnId: z.number(), + step: z.number(), + stepId: z.string().optional(), + reason: z.string(), + message: z.string().optional(), +}); + +export class TurnStepInterrupted extends AgentEvent2 { + static override readonly type = 'turn.step.interrupted'; + static override readonly durable = true; + static override readonly observable = true; + static override readonly schema = turnStepInterruptedSchema; +} +export interface TurnStepInterrupted extends TurnStepInterruptedPayload {} + +export interface TurnStepRetryingPayload { + readonly agentId: string; + readonly turnId: number; + readonly step: number; + readonly stepId?: string; + readonly failedAttempt: number; + readonly nextAttempt: number; + readonly maxAttempts: number; + readonly delayMs: number; + readonly errorName: string; + readonly errorMessage: string; + readonly statusCode?: number; +} + +const turnStepRetryingSchema = z.object({ + agentId: z.string(), + turnId: z.number(), + step: z.number(), + stepId: z.string().optional(), + failedAttempt: z.number(), + nextAttempt: z.number(), + maxAttempts: z.number(), + delayMs: z.number(), + errorName: z.string(), + errorMessage: z.string(), + statusCode: z.number().optional(), +}); + +export class TurnStepRetrying extends AgentEvent2 { + static override readonly type = 'turn.step.retrying'; + static override readonly durable = true; + static override readonly observable = true; + static override readonly schema = turnStepRetryingSchema; +} +export interface TurnStepRetrying extends TurnStepRetryingPayload {} + +export interface AssistantDeltaPayload { + readonly agentId: string; + readonly turnId: number; + readonly delta: string; +} + +export class AssistantDelta extends AgentEvent2 { + static override readonly type = 'assistant.delta'; + static override readonly observable = true; +} +export interface AssistantDelta extends AssistantDeltaPayload {} + +export interface ThinkingDeltaPayload { + readonly agentId: string; + readonly turnId: number; + readonly delta: string; +} + +export class ThinkingDelta extends AgentEvent2 { + static override readonly type = 'thinking.delta'; + static override readonly observable = true; +} +export interface ThinkingDelta extends ThinkingDeltaPayload {} + +export interface ToolCallDeltaPayload { + readonly agentId: string; + readonly turnId: number; + readonly toolCallId: string; + readonly name?: string; + readonly argumentsPart?: string; +} + +export class ToolCallDelta extends AgentEvent2 { + static override readonly type = 'tool.call.delta'; + static override readonly observable = true; +} +export interface ToolCallDelta extends ToolCallDeltaPayload {} + +registerEvent2Class(TurnStepInterrupted); +registerEvent2Class(TurnStepRetrying); + +export interface TurnStartedEvent extends Omit { + readonly type: 'turn.started'; +} + +export interface TurnStepStartedEvent extends Omit { + readonly type: 'turn.step.started'; +} + +export interface TurnStepCompletedEvent extends Omit { + readonly type: 'turn.step.completed'; +} + +export interface TurnStepRetryingEvent extends Omit { + readonly type: 'turn.step.retrying'; +} + +export interface TurnStepInterruptedEvent extends Omit { + readonly type: 'turn.step.interrupted'; +} + +export interface AssistantDeltaEvent extends Omit { + readonly type: 'assistant.delta'; +} + +export interface ThinkingDeltaEvent extends Omit { + readonly type: 'thinking.delta'; +} diff --git a/packages/agent-core-v2/src/agent/loop/turnOps.ts b/packages/agent-core-v2/src/agent/loop/turnOps.ts new file mode 100644 index 0000000000000000000000000000000000000000..c624a2fc2ceb7c797cdf42f602d3ddf173978df8 --- /dev/null +++ b/packages/agent-core-v2/src/agent/loop/turnOps.ts @@ -0,0 +1,203 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { z } from 'zod'; + +import type { KimiErrorPayload } from '#/_base/errors/serialize'; +import { + ContextAppendLoopEvent, + ContextApplyCompaction, + ContextClear, + ContextUndo, +} from '#/agent/contextMemory/contextEvents'; +import { isUndoAnchorOrigin } from '#/agent/contextMemory/conversationTime'; +import type { PromptOrigin } from '#/agent/contextMemory/types'; +import { AgentEvent2, type SerializedEvent2 } from '#/app/event/event2'; +import type { ContentPart } from '#human/llm/message'; +import { defineState } from '#/state/state'; + +import type { TurnEndReason, TurnInterruptReason } from './turnEvents'; + +export interface TurnModelState { + readonly nextTurnId: number; + readonly cancelledTurnIds: readonly number[]; + readonly anchorTurnIds: readonly number[]; + readonly lastEnded?: { + readonly turnId: number; + readonly reason: 'completed' | 'cancelled' | 'failed' | 'blocked'; + readonly durationMs?: number; + }; +} + +const turnInputShape = { + agentId: z.string(), + input: z.custom(), + origin: z.custom(), +}; + +const turnPromptSchema = z.object({ + agentId: z.string(), + input: z.custom(), + origin: z.custom(), + promptId: z.string().optional(), + turnId: z.number().optional(), +}); + +export class TurnPrompt extends AgentEvent2> { + static override readonly type = 'turn.prompt'; + static override readonly durable = true; + static override readonly schema = turnPromptSchema; +} +export interface TurnPrompt { + readonly agentId: string; + readonly input: readonly ContentPart[]; + readonly origin: PromptOrigin; + readonly promptId?: string; + readonly turnId?: number; +} + +const turnSteerSchema = z.object(turnInputShape); + +export class TurnSteer extends AgentEvent2> { + static override readonly type = 'turn.steer'; + static override readonly durable = true; + static override readonly observable = true; + static override readonly schema = turnSteerSchema; +} +export interface TurnSteer { + readonly agentId: string; + readonly input: readonly ContentPart[]; + readonly origin: PromptOrigin; +} + +const turnCancelSchema = z.object({ + agentId: z.string(), + turnId: z.number().optional(), + target: z.enum(['active', 'queued']).optional(), + reason: z.enum(['user_cancelled', 'aborted']).optional(), +}); + +export class TurnCancel extends AgentEvent2> { + static override readonly type = 'turn.cancel'; + static override readonly durable = true; + static override readonly schema = turnCancelSchema; +} +export interface TurnCancel { + readonly agentId: string; + readonly turnId?: number; + readonly target?: 'active' | 'queued'; + readonly reason?: 'user_cancelled' | 'aborted'; +} + +const turnEndedSchema = z.object({ + agentId: z.string(), + turnId: z.number(), + reason: z.enum(['completed', 'cancelled', 'failed', 'blocked']), + error: z.custom().optional(), + durationMs: z.number().optional(), + stopReason: z.string().optional(), +}); + +export interface TurnEndedPayload { + readonly agentId: string; + readonly turnId: number; + readonly reason: 'completed' | 'cancelled' | 'failed' | 'blocked'; + readonly error?: KimiErrorPayload; + readonly durationMs?: number; + readonly interruptReason?: TurnInterruptReason; + readonly stopReason?: string; +} + +export class TurnEnded extends AgentEvent2 { + static override readonly type = 'turn.ended'; + static override readonly durable = true; + static override readonly observable = true; + static override readonly schema = turnEndedSchema; + + override serialize(): SerializedEvent2 { + const record: Record = { + type: this.type, + agentId: this.agentId, + turnId: this.turnId, + reason: this.reason, + }; + if (this.error !== undefined) record['error'] = this.error; + if (this.durationMs !== undefined) record['durationMs'] = this.durationMs; + if (this.stopReason !== undefined) record['stopReason'] = this.stopReason; + record['time'] = this.time; + return record as SerializedEvent2; + } +} +export interface TurnEnded extends TurnEndedPayload {} + +export const turnKey = defineState( + 'turn', + (): TurnModelState => ({ nextTurnId: 0, cancelledTurnIds: [], anchorTurnIds: [] }), +).replayable({ schema: z.custom() }) + .on(ContextAppendLoopEvent, (s, e) => { + const { event } = e; + if (event.type === 'tool.result' || event.turnId === undefined) return; + const turnId = Number.parseInt(event.turnId, 10); + if (!Number.isInteger(turnId)) return; + let next: TurnModelState = s; + if (turnId >= next.nextTurnId) next = advanceTurnClock(next, turnId + 1); + if (next.lastEnded !== undefined && turnId > next.lastEnded.turnId) { + next = { ...next, lastEnded: undefined }; + } + if (next !== s) return next; + }) + .on(TurnPrompt, (s, e) => { + const assigned = e.turnId ?? s.nextTurnId; + const next = advanceTurnClock(s, assigned + 1); + if (!isUndoAnchorOrigin(e.origin)) return next; + return { ...next, anchorTurnIds: [...s.anchorTurnIds, assigned] }; + }) + .on(TurnSteer, () => {}) + .on(ContextUndo, (s, e) => { + const firstRemoved = s.anchorTurnIds[s.anchorTurnIds.length - e.count]; + const lastEnded = s.lastEnded; + return { + ...s, + anchorTurnIds: s.anchorTurnIds.slice(0, Math.max(0, s.anchorTurnIds.length - e.count)), + lastEnded: + lastEnded !== undefined && + (firstRemoved === undefined || lastEnded.turnId >= firstRemoved) + ? undefined + : lastEnded, + }; + }) + .on(ContextApplyCompaction, (s) => ({ ...s, anchorTurnIds: [] })) + .on(ContextClear, (s) => ({ ...s, anchorTurnIds: [] })) + .on(TurnCancel, (s, e) => { + if (e.target === undefined || e.turnId === undefined) return; + if (e.turnId < s.nextTurnId) return; + return advanceTurnClock(s, s.nextTurnId, [...s.cancelledTurnIds, e.turnId]); + }) + .on(TurnEnded, (s, e) => ({ + ...s, + lastEnded: { turnId: e.turnId, reason: e.reason, durationMs: e.durationMs }, + })); + +export interface TurnEndedEvent { + readonly type: 'turn.ended'; + readonly time?: number; + readonly turnId: number; + readonly reason: TurnEndReason; + readonly error?: KimiErrorPayload; + readonly durationMs?: number; + readonly interruptReason?: TurnInterruptReason; +} + +function advanceTurnClock( + state: TurnModelState, + nextTurnId: number, + cancelledTurnIds: readonly number[] = state.cancelledTurnIds, +): TurnModelState { + const pendingCancellations = new Set( + cancelledTurnIds.filter((turnId) => turnId >= nextTurnId), + ); + while (pendingCancellations.delete(nextTurnId)) nextTurnId += 1; + return { + ...state, + nextTurnId, + cancelledTurnIds: [...pendingCancellations].toSorted((a, b) => a - b), + }; +} diff --git a/packages/agent-core-v2/src/agent/mcp/mcp.ts b/packages/agent-core-v2/src/agent/mcp/mcp.ts new file mode 100644 index 0000000000000000000000000000000000000000..8dd2db51998a3657941a3ac1f936f37350582383 --- /dev/null +++ b/packages/agent-core-v2/src/agent/mcp/mcp.ts @@ -0,0 +1,30 @@ +import type { ToolDescription as KosongTool } from '#human/llm/message'; + +import { createDecorator } from "#/_base/di/instantiation"; +import { type IDisposable } from "#/_base/di/lifecycle"; +import type { McpServerEntry } from '#/mcpCore/connection-manager'; +import type { McpOAuthService } from '#/mcpCore/oauth/service'; +import type { MCPClient, MCPToolDefinition } from '#/mcpCore/types'; + +export interface McpResolvedServer { + readonly client: MCPClient; + readonly tools: readonly KosongTool[]; + readonly rawTools: readonly MCPToolDefinition[]; + readonly enabledNames: ReadonlySet; + readonly deferred: boolean; +} + +export interface IAgentMcpService { + readonly _serviceBrand: undefined; + + readonly oauthService: McpOAuthService | undefined; + waitForInitialLoad(signal?: AbortSignal): Promise; + initialLoadDurationMs(): number; + list(): readonly McpServerEntry[]; + resolved(name: string): McpResolvedServer | undefined; + getRemoteServerUrl(name: string): string | undefined; + reconnect(name: string, signal?: AbortSignal): Promise; + onStatusChange(listener: (entry: McpServerEntry) => void): IDisposable; +} + +export const IAgentMcpService = createDecorator('agentMcpService'); diff --git a/packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts b/packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts new file mode 100644 index 0000000000000000000000000000000000000000..2a4458967c6f939bb6b596483f8497462f48a4bc --- /dev/null +++ b/packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts @@ -0,0 +1,58 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { z } from 'zod'; + +import { AgentEvent2 } from '#/app/event/event2'; +import type { MCPToolDefinition } from '#/mcpCore/types'; +import { defineState } from '#/state/state'; + +export interface McpToolCollision { + readonly qualified: string; + readonly toolName: string; + readonly collidesWith: + | { readonly kind: 'same_server'; readonly toolName: string } + | { readonly kind: 'other_server'; readonly serverName: string }; +} + +export interface McpDiscoveryState { + readonly seen: readonly string[]; +} + +const mcpToolCollisionSchema = z.object({ + qualified: z.string(), + toolName: z.string(), + collidesWith: z.union([ + z.object({ kind: z.literal('same_server'), toolName: z.string() }), + z.object({ kind: z.literal('other_server'), serverName: z.string() }), + ]), +}); + +const mcpToolsDiscoveredSchema = z.object({ + agentId: z.string(), + serverName: z.string(), + hash: z.string(), + tools: z.custom(), + enabledNames: z.array(z.string()).readonly(), + collisions: z.array(mcpToolCollisionSchema).readonly().optional(), +}); + +export class McpToolsDiscovered extends AgentEvent2> { + static override readonly type = 'mcp.tools_discovered'; + static override readonly durable = true; + static override readonly schema = mcpToolsDiscoveredSchema; +} +export interface McpToolsDiscovered { + readonly agentId: string; + readonly serverName: string; + readonly hash: string; + readonly tools: readonly MCPToolDefinition[]; + readonly enabledNames: readonly string[]; + readonly collisions?: readonly McpToolCollision[]; +} + +export const mcpDiscoveryKey = defineState('mcp.discovery', (): McpDiscoveryState => ({ seen: [] })) + .replayable({ schema: z.custom() }) + .on(McpToolsDiscovered, (s, e) => { + const key = `${e.serverName}\n${e.hash}`; + if (s.seen.includes(key)) return; + s.seen = [...s.seen, key]; +}); diff --git a/packages/agent-core-v2/src/agent/mcp/mcpEvents.ts b/packages/agent-core-v2/src/agent/mcp/mcpEvents.ts new file mode 100644 index 0000000000000000000000000000000000000000..b760531152b6899ac4892507a87c84aa063634e9 --- /dev/null +++ b/packages/agent-core-v2/src/agent/mcp/mcpEvents.ts @@ -0,0 +1,44 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import type { KimiErrorPayload } from '#/_base/errors/serialize'; +import { AgentEvent2, type AgentDomainTrait } from '#/app/event/event2'; + +export interface McpServerStatusPayload { + readonly name: string; + readonly transport: 'stdio' | 'http' | 'sse'; + readonly status: 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth' | 'removed'; + readonly toolCount: number; + readonly error?: string; +} + +export interface McpServerStatusEventPayload { + readonly agentId: string; + readonly server: McpServerStatusPayload; +} + +export class McpServerStatus extends AgentEvent2 { + static override readonly type = 'mcp.server.status'; + static override readonly observable = true; +} +export interface McpServerStatus extends McpServerStatusEventPayload {} + +export type ToolListUpdatedReason = 'mcp.connected' | 'mcp.disconnected' | 'mcp.failed'; + +export interface ToolListUpdatedPayload { + readonly agentId: string; + readonly reason: ToolListUpdatedReason; + readonly serverName: string; +} + +export class ToolListUpdated extends AgentEvent2 { + static override readonly type = 'tool.list.updated'; + static override readonly observable = true; +} +export interface ToolListUpdated extends ToolListUpdatedPayload {} + +export class AgentErrorEvent extends AgentEvent2 { + static override readonly type = 'error'; + static override readonly observable = true; +} +export interface AgentErrorEvent extends KimiErrorPayload { + readonly agentId: string; +} diff --git a/packages/agent-core-v2/src/agent/mcp/mcpService.ts b/packages/agent-core-v2/src/agent/mcp/mcpService.ts new file mode 100644 index 0000000000000000000000000000000000000000..40f6d59be57c424f88201e054cda44dee50f0924 --- /dev/null +++ b/packages/agent-core-v2/src/agent/mcp/mcpService.ts @@ -0,0 +1,397 @@ +import { createHash } from 'node:crypto'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { defineState } from '#/state/state'; +import type { ToolDescription as KosongTool } from '#human/llm/message'; + +import { type IDisposable } from "#/_base/di/lifecycle"; +import { Service } from "#/_base/di/service"; +import { ErrorCodes, makeErrorPayload } from "#/errors"; +import { abortable } from '#/_base/utils/abort'; +import { IAgentProfileService } from '#/agent/profile/profile'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { ISessionMediaStore } from '#/agent/media/sessionMediaStore'; +import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; +import { IAgentLoopService } from '#/agent/loop/loop'; +import { createMcpAuthTool } from '#/agent/mcp/tools/auth'; +import { createMcpTool } from '#/agent/mcp/tools/mcp'; +import { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; +import type { McpServerEntry } from '#/mcpCore/connection-manager'; +import { IAgentMcpService } from './mcp'; +import { qualifyMcpToolName } from '#/mcpCore/tool-naming'; +import type { MCPClient, MCPToolDefinition } from '#/mcpCore/types'; +import { IEventDispatcher } from '#/state/eventDispatcher'; +import { + mcpDiscoveryKey, + McpToolsDiscovered, + type McpToolCollision, +} from './mcpDiscoveryOps'; +import { AgentErrorEvent, McpServerStatus, ToolListUpdated } from './mcpEvents'; + +interface McpToolRegistration { + readonly disposable: IDisposable; + readonly serverName: string; +} + +export const mcpMcpToolsByServerKey = defineState>( + 'mcp.mcpToolsByServer', + () => new Map(), +); +export const mcpDiscoveryWritesReadyKey = defineState( + 'mcp.discoveryWritesReady', + () => false, +); + +export class AgentMcpService extends Service implements IAgentMcpService { + declare readonly _serviceBrand: undefined; + private readonly mcpTools = new Map(); + private readonly pendingDiscoveries: Array<() => void> = []; + + constructor( + @ISessionMcpHandle private readonly mcpHandle: ISessionMcpHandle, + @IAgentToolRegistryService private readonly registry: IAgentToolRegistryService, + @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, + @IAgentLoopService loop: IAgentLoopService, + @IEventDispatcher private readonly dispatcher: IEventDispatcher, + @ITelemetryService private readonly telemetry: ITelemetryService, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @IAgentStateService private readonly states: IAgentStateService, + @IAgentProfileService private readonly profile: IAgentProfileService, + @ISessionMediaStore private readonly attachmentStore: ISessionMediaStore, + ) { + super(); + this.states.contributeState(mcpDiscoveryKey); + this.states.contributeState(mcpMcpToolsByServerKey); + this.states.contributeState(mcpDiscoveryWritesReadyKey); + this.attachMcpTools(); + loop.hooks.onWillBeginStep.register('mcp', async (ctx, next) => { + await this.waitForInitialLoad(ctx.signal); + await next(); + }); + this._register( + toolExecutor.onWillExecuteTool((event) => { + event.waitUntil(this.waitForInitialLoad(event.signal)); + }), + ); + this._register( + this.dispatcher.hooks.onDidRestore.register('mcp', async (_ctx, next) => { + this.flushPendingDiscoveries(); + await next(); + }), + ); + } + + private get mcpToolsByServer(): Map { + 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.mcpHandle.connectionManager.oauthService; + } + + waitForInitialLoad(signal?: AbortSignal): Promise { + const ready = this.mcpHandle.ready; + return signal === undefined ? ready : abortable(ready, signal); + } + + initialLoadDurationMs(): number { + return this.mcpHandle.connectionManager.initialLoadDurationMs(); + } + + list() { + return this.mcpHandle.connectionManager.list(); + } + + resolved(name: string) { + return this.mcpHandle.connectionManager.resolved(name); + } + + getRemoteServerUrl(name: string) { + return this.mcpHandle.connectionManager.getRemoteServerUrl(name); + } + + async reconnect(name: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + await this.mcpHandle.connectionManager.reconnect(name); + signal?.throwIfAborted(); + } + + private reconnectForToolCall( + serverName: string, + staleClient: MCPClient, + signal?: AbortSignal, + ): Promise { + const work = this.joinHealedOrReconnect(serverName, staleClient); + return signal === undefined ? work : abortable(work, signal); + } + + private async joinHealedOrReconnect( + serverName: string, + staleClient: MCPClient, + ): Promise { + const healed = this.resolved(serverName)?.client; + if (healed !== undefined && healed !== staleClient) return healed; + await this.mcpHandle.connectionManager.reconnectAndJoin(serverName); + const current = this.resolved(serverName)?.client; + return current !== undefined && current !== staleClient ? current : undefined; + } + + onStatusChange(listener: Parameters[0]) { + const unsubscribe = this.mcpHandle.connectionManager.onStatusChange(listener); + return { + dispose: unsubscribe, + }; + } + + private attachMcpTools(): void { + for (const entry of this.list()) { + this.handleMcpServerStatusChange(entry); + } + this._register( + this.onStatusChange((entry) => { + this.handleMcpServerStatusChange(entry); + }), + ); + } + + private handleMcpServerStatusChange(entry: McpServerEntry): void { + if (!this.mcpHandle.isBaselineServer(entry.name)) return; + void this.dispatcher.dispatch( + new McpServerStatus({ + agentId: this.scopeContext.agentId, + server: { + name: entry.name, + transport: entry.transport, + status: entry.status, + toolCount: entry.toolCount, + error: entry.error, + }, + }), + ); + if (entry.status === 'connected') { + this.registerConnectedMcpServer(entry); + return; + } + if (entry.status === 'needs-auth') { + this.registerNeedsAuthMcpServer(entry); + return; + } + if (entry.status === 'failed' || entry.status === 'pending' || entry.status === 'removed') { + return; + } + if (entry.status === 'disabled') { + const removed = this.unregisterMcpServer(entry.name); + if (removed) { + void this.dispatcher.dispatch( + new ToolListUpdated({ + agentId: this.scopeContext.agentId, + reason: 'mcp.disconnected', + serverName: entry.name, + }), + ); + } + } + } + + private registerConnectedMcpServer(entry: McpServerEntry): void { + const resolved = this.resolved(entry.name); + if (resolved === undefined) return; + const result = this.registerMcpServer( + entry.name, + resolved.client, + resolved.tools, + resolved.enabledNames, + resolved.deferred, + ); + this.emitMcpToolCollisions(entry.name, result.collisions); + this.recordDiscovery(entry.name, resolved.rawTools, resolved.enabledNames, result.collisions); + void this.dispatcher.dispatch( + new ToolListUpdated({ + agentId: this.scopeContext.agentId, + reason: 'mcp.connected', + serverName: entry.name, + }), + ); + } + + private registerNeedsAuthMcpServer(entry: McpServerEntry): void { + this.unregisterMcpServer(entry.name); + const oauthService = this.oauthService; + const serverUrl = this.getRemoteServerUrl(entry.name); + if (oauthService === undefined || serverUrl === undefined) return; + const tool = createMcpAuthTool({ + serverName: entry.name, + serverUrl, + oauthService, + reconnect: (signal) => this.reconnect(entry.name, signal), + }); + const deferred = this.mcpHandle.connectionManager.configOf(entry.name)?.deferred === true; + const disposable = this._register( + this.registry.register(tool, { + source: 'mcp', + disclosure: deferred ? 'deferred' : 'inline', + }), + ); + this.mcpTools.set(tool.name, { disposable, serverName: entry.name }); + this.mcpToolsByServer.set(entry.name, [tool.name]); + void this.dispatcher.dispatch( + new ToolListUpdated({ + agentId: this.scopeContext.agentId, + reason: 'mcp.connected', + serverName: entry.name, + }), + ); + } + + private registerMcpServer( + serverName: string, + client: MCPClient, + tools: readonly KosongTool[], + enabledTools: ReadonlySet, + deferred: boolean, + ): { + readonly registered: readonly string[]; + readonly collisions: readonly McpToolCollision[]; + } { + this.unregisterMcpServer(serverName); + const qualifiedNames: string[] = []; + const collisions: McpToolCollision[] = []; + const seenInThisCall = new Map(); + for (const tool of tools) { + if (!enabledTools.has(tool.name)) continue; + const qualified = qualifyMcpToolName(serverName, tool.name); + const firstInThisCall = seenInThisCall.get(qualified); + if (firstInThisCall !== undefined) { + collisions.push({ + qualified, + toolName: tool.name, + collidesWith: { kind: 'same_server', toolName: firstInThisCall }, + }); + continue; + } + const existingEntry = this.mcpTools.get(qualified); + if (existingEntry !== undefined) { + collisions.push({ + qualified, + toolName: tool.name, + collidesWith: { kind: 'other_server', serverName: existingEntry.serverName }, + }); + continue; + } + seenInThisCall.set(qualified, tool.name); + const disposable = this._register( + this.registry.register( + createMcpTool(qualified, tool, client, { + attachmentStore: this.attachmentStore, + telemetry: this.telemetry, + providerType: () => this.profile.getModelProviderType(), + reconnect: (signal) => this.reconnectForToolCall(serverName, client, signal), + isRemoved: () => + this.mcpHandle.connectionManager.get(serverName)?.status === 'removed', + }), + { source: 'mcp', disclosure: deferred ? 'deferred' : 'inline' }, + ), + ); + this.mcpTools.set(qualified, { disposable, serverName }); + qualifiedNames.push(qualified); + } + this.mcpToolsByServer.set(serverName, qualifiedNames); + return { registered: qualifiedNames, collisions }; + } + + private unregisterMcpServer(serverName: string): boolean { + const names = this.mcpToolsByServer.get(serverName); + if (names === undefined) return false; + for (const name of names) { + const entry = this.mcpTools.get(name); + entry?.disposable.dispose(); + this.mcpTools.delete(name); + } + this.mcpToolsByServer.delete(serverName); + return true; + } + + private recordDiscovery( + serverName: string, + rawTools: readonly MCPToolDefinition[], + enabledNames: ReadonlySet, + collisions: readonly McpToolCollision[], + ): void { + const enabledNamesSnapshot = [...enabledNames].toSorted((a, b) => a.localeCompare(b)); + const work = (): void => { + const hash = createHash('sha256') + .update(JSON.stringify({ tools: rawTools, enabledNames: enabledNamesSnapshot, collisions })) + .digest('hex'); + const key = `${serverName}\n${hash}`; + if (this.states.get(mcpDiscoveryKey).seen.includes(key)) return; + void this.dispatcher.dispatch( + new McpToolsDiscovered({ + agentId: this.scopeContext.agentId, + serverName, + hash, + tools: rawTools, + enabledNames: enabledNamesSnapshot, + collisions: collisions.length > 0 ? collisions : undefined, + }), + ); + }; + if (!this.discoveryWritesReady) { + this.pendingDiscoveries.push(work); + return; + } + work(); + } + + private flushPendingDiscoveries(): void { + this.discoveryWritesReady = true; + const pending = this.pendingDiscoveries.splice(0); + for (const work of pending) { + work(); + } + } + + private emitMcpToolCollisions( + serverName: string, + collisions: readonly McpToolCollision[], + ): void { + if (collisions.length === 0) return; + const summary = collisions + .map((collision) => + collision.collidesWith.kind === 'same_server' + ? `"${collision.toolName}" -> ${collision.qualified} (collides with "${collision.collidesWith.toolName}" from the same server)` + : `"${collision.toolName}" -> ${collision.qualified} (collides with server "${collision.collidesWith.serverName}")`, + ) + .join('; '); + void this.dispatcher.dispatch( + new AgentErrorEvent({ + ...makeErrorPayload( + ErrorCodes.MCP_TOOL_NAME_COLLISION, + `MCP server "${serverName}" registered ${collisions.length} tool name` + + `${collisions.length === 1 ? '' : 's'} ` + + `that collide with existing qualified names; the losing tools were dropped: ${summary}`, + { details: { serverName, collisions: collisions as readonly unknown[] } }, + ), + agentId: this.scopeContext.agentId, + }), + ); + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentMcpService, + AgentMcpService, + ScopeActivation.OnScopeCreated, + 'mcp', +); diff --git a/packages/agent-core-v2/src/agent/mcp/output.ts b/packages/agent-core-v2/src/agent/mcp/output.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa0e0f675bafe2d4077e87d467e9a3fd664e167b --- /dev/null +++ b/packages/agent-core-v2/src/agent/mcp/output.ts @@ -0,0 +1,415 @@ +import { createHash } from 'node:crypto'; +import { Readable } from 'node:stream'; +import { isDeepStrictEqual } from 'node:util'; + +import type { ContentPart } from '#human/llm/message'; +import type { ITelemetryService } from '#/app/telemetry/telemetry'; +import type { ExecutableToolResult } from '#/tool/toolContract'; +import { textExtensionForMime } from '#/_base/utils/fileMeta'; + +import { compressImageContentParts, gateImageFormatParts } from '#/agent/media/image-compress'; +import { + buildUnsupportedImageNotice, + isModelAcceptedImageMime, + parseImageDataUrl, + resolveEffectiveImageMime, + decodeBase64Prefix, +} from '#/agent/media/image-format-policy'; +import { persistOriginalImage } from '#/agent/media/image-originals'; +import type { ISessionMediaStore } from '#/agent/media/sessionMediaStore'; +import { buildDaemonFileUrl, mediaExtensionForMime } from '#/agent/media/mediaRef'; +import type { MCPContentBlock, MCPToolResult } from '#/mcpCore/types'; + +export interface McpOutputOptions { + readonly signal?: AbortSignal; + readonly attachmentStore?: ISessionMediaStore; + readonly originalsDir?: string; + readonly telemetry?: ITelemetryService; + readonly providerType?: string; +} + +export const MCP_MAX_BINARY_PART_BYTES = 10 * 1024 * 1024; +const MCP_MAX_BINARY_PART_CHARS = Math.ceil((MCP_MAX_BINARY_PART_BYTES * 4) / 3); +const MCP_MAX_INLINE_NOTICES_CHARS = 4096; + +function binaryPartTooLargeNotice(kind: 'image' | 'audio' | 'video', urlLength: number): string { + const approxMb = ((urlLength * 3) / 4 / (1024 * 1024)).toFixed(1); + const capMb = String(MCP_MAX_BINARY_PART_BYTES / (1024 * 1024)); + return `[${kind}_url dropped: ~${approxMb} MB exceeds ${capMb} MB per-part limit. Try a smaller resource.]`; +} + +function droppedBlockNotice(reason: string): ContentPart { + return { type: 'text', text: `[MCP content dropped: ${reason}]` }; +} + +export function convertMCPContentBlock(block: MCPContentBlock, providerType?: string): ContentPart { + if (block.type === 'text' && typeof block.text === 'string') { + return { type: 'text', text: block.text }; + } + + if (block.type === 'image' && typeof block.data === 'string') { + const mimeType = block.mimeType ?? 'image/png'; + return { + type: 'image_url', + imageUrl: { url: `data:${mimeType};base64,${block.data}` }, + }; + } + + if (block.type === 'audio' && typeof block.data === 'string') { + const mimeType = block.mimeType ?? 'audio/mpeg'; + return { + type: 'audio_url', + audioUrl: { url: `data:${mimeType};base64,${block.data}` }, + }; + } + + if (block.type === 'resource' && typeof block.resource === 'object' && block.resource !== null) { + const res = block.resource; + if (typeof res.text === 'string') { + return { type: 'text', text: res.text }; + } + if (typeof res.blob === 'string') { + const mimeType = res.mimeType ?? 'application/octet-stream'; + if (mimeType.startsWith('image/')) { + return { + type: 'image_url', + imageUrl: { url: `data:${mimeType};base64,${res.blob}` }, + }; + } + if (mimeType.startsWith('audio/')) { + return { + type: 'audio_url', + audioUrl: { url: `data:${mimeType};base64,${res.blob}` }, + }; + } + if (mimeType.startsWith('video/')) { + return { + type: 'video_url', + videoUrl: { url: `data:${mimeType};base64,${res.blob}` }, + }; + } + const approxMb = ((res.blob.length * 3) / 4 / (1024 * 1024)).toFixed(1); + return droppedBlockNotice( + `resource blob with unsupported mimeType "${mimeType}" (~${approxMb} MB, uri: ${res.uri}) was not delivered.`, + ); + } + return droppedBlockNotice(`resource (uri: ${res.uri}) carried no text or blob payload.`); + } + + if (block.type === 'resource_link' && typeof block.uri === 'string') { + const mimeType = block.mimeType ?? 'application/octet-stream'; + if (mimeType.startsWith('image/')) { + if (!isModelAcceptedImageMime(mimeType, providerType)) { + return { + type: 'text', + text: buildUnsupportedImageNotice(mimeType, block.uri, providerType), + }; + } + return { type: 'image_url', imageUrl: { url: block.uri } }; + } + if (mimeType.startsWith('audio/')) { + return { type: 'audio_url', audioUrl: { url: block.uri } }; + } + if (mimeType.startsWith('video/')) { + return { type: 'video_url', videoUrl: { url: block.uri } }; + } + return droppedBlockNotice( + `resource_link with unsupported mimeType "${mimeType}" was not delivered. Fetch it directly if needed: ${block.uri}`, + ); + } + + return droppedBlockNotice(`content block of unsupported type "${block.type}" was not delivered.`); +} + +export async function mcpResultToExecutableOutput( + result: MCPToolResult, + qualifiedToolName: string, + options: McpOutputOptions = {}, +): Promise { + options.signal?.throwIfAborted(); + const converted: ContentPart[] = []; + const attachmentNotices: string[] = []; + const preservedUrls = new Set(); + let omittedAttachment = false; + const preserveInlineMedia = async (url: string): Promise => { + options.signal?.throwIfAborted(); + if (preservedUrls.has(url)) return; + const parsed = parseImageDataUrl(url); + if (parsed === null) return; + preservedUrls.add(url); + const mime = parsed.mimeType.startsWith('image/') + ? resolveEffectiveImageMime(parsed.mimeType, decodeBase64Prefix(parsed.base64)) + : parsed.mimeType; + attachmentNotices.push(await preserveAttachment(parsed.base64, mime, options)); + }; + for (const block of result.content) { + options.signal?.throwIfAborted(); + const part = convertMCPContentBlock(block, options.providerType); + if (part.type === 'image_url' && options.attachmentStore !== undefined) await preserveInlineMedia(part.imageUrl.url); + if (part.type === 'audio_url') await preserveInlineMedia(part.audioUrl.url); + if (part.type === 'video_url') await preserveInlineMedia(part.videoUrl.url); + const gated = gateImageFormatParts([part], options.providerType); + converted.push(...gated); + if (part.type === 'image_url' && gated[0]?.type === 'text') { + omittedAttachment = true; + await preserveInlineMedia(part.imageUrl.url); + } + if (part.type === 'text' && block.type === 'resource' && + typeof block.resource?.blob === 'string' && typeof block.resource.text !== 'string') { + omittedAttachment = true; + attachmentNotices.push(await preserveAttachment( + block.resource.blob, + block.resource.mimeType ?? 'application/octet-stream', + options, + )); + } + } + + const wrapped = wrapMediaOnly(converted, qualifiedToolName); + const hasStructuredCopy = result.structuredContent !== undefined && converted.some((part) => { + if (part.type !== 'text') return false; + try { + return isDeepStrictEqual(parseComparableJson(part.text), result.structuredContent); + } catch { + return false; + } + }); + const structuredExtras: Record = {}; + if (result.structuredContent !== undefined && !hasStructuredCopy) { + structuredExtras['structuredContent'] = result.structuredContent; + } + if (result._meta !== undefined) { + const meta = stripReservedMetaKeys(result._meta); + if (meta !== undefined) { + structuredExtras['_meta'] = meta; + } + } + if (Object.keys(structuredExtras).length > 0) { + const serialized = serializeStructuredExtras(structuredExtras); + if (serialized !== undefined) { + wrapped.push({ + type: 'text', + text: `\n\n${serialized}\n`, + }); + } + } + + const compressed = await compressImageContentParts(wrapped, { + signal: options.signal, + telemetry: options.telemetry, + telemetrySource: 'mcp_tool_result', + providerType: options.providerType, + annotate: { + persistOriginal: async (bytes, mimeType) => { + if (options.attachmentStore !== undefined) { + const saved = await saveAttachment(bytes, mimeType, options.attachmentStore, options.signal); + attachmentNotices.push(attachmentNotice(saved, mimeType, bytes.length)); + return saved.reference; + } + return persistOriginalImage( + bytes, + mimeType, + options.originalsDir === undefined ? {} : { dir: options.originalsDir }, + ); + }, + }, + }); + const capped = await applyBinaryPartCap(compressed.parts, preserveInlineMedia); + const notices = await attachmentDetails( + [...compressed.captions, ...attachmentNotices, ...capped.notices], options, + ); + const parts = [...capped.parts]; + if (notices.content.length > 0) parts.push({ type: 'text', text: notices.content }); + const output = collapseSingleText(parts); + const base = { + output, + truncated: capped.truncated || omittedAttachment ? true : undefined, + spill: notices.suffix.length > 0 ? { suffix: notices.suffix } : undefined, + }; + return result.isError ? { ...base, isError: true } : base; +} + +async function attachmentDetails( + notices: readonly string[], + options: McpOutputOptions, +): Promise<{ readonly content: string; readonly suffix: string }> { + options.signal?.throwIfAborted(); + const content = [...new Set(notices)].join('\n'); + if (content.length <= MCP_MAX_INLINE_NOTICES_CHARS) return { content, suffix: content }; + try { + if (options.attachmentStore === undefined) throw new Error('Session attachment storage is unavailable'); + const saved = await saveAttachment(Buffer.from(content, 'utf8'), 'text/plain', options.attachmentStore, options.signal); + const pointer = [ + ...(saved.path === undefined ? [] : [`MCP attachment details saved at: ${JSON.stringify(saved.path)}`]), + `Attachment details reference: ${JSON.stringify(saved.reference)}`, + `Session-relative attachment details: ${JSON.stringify(saved.relativePath)}`, + 'Pass the attachment details reference to Read to retrieve all original attachment references and compression details in the current session.', + ].join('\n'); + return { content: pointer, suffix: pointer }; + } catch { + options.signal?.throwIfAborted(); + const suffix = 'The complete MCP attachment list could not be saved separately. Attachment details are included in the tool output and may be truncated. Do not repeat the MCP call automatically.'; + return { content: `${content}\n${suffix}`, suffix }; + } +} + +async function preserveAttachment( + base64: string, + mimeType: string, + options: McpOutputOptions, +): Promise { + options.signal?.throwIfAborted(); + try { + if (options.attachmentStore === undefined) throw new Error('Session attachment storage is unavailable'); + const compact = base64.replaceAll(/\s/g, ''); + const bytes = Buffer.from(compact, 'base64'); + const canonical = bytes.toString('base64'); + if (canonical !== compact && canonical.replace(/=+$/, '') !== compact) { + throw new Error('Invalid base64 attachment'); + } + const saved = await saveAttachment(bytes, mimeType, options.attachmentStore, options.signal); + return attachmentNotice(saved, mimeType, bytes.length); + } catch (error) { + options.signal?.throwIfAborted(); + return `Original attachment could not be saved (${JSON.stringify(mimeType)}): ${error instanceof Error ? error.message : String(error)}. No readable original path is available; original attachment preservation is incomplete. Do not repeat the MCP call automatically.`; + } +} + +interface SavedAttachment { + readonly path?: string; + readonly reference: string; + readonly relativePath: string; +} + +function attachmentNotice(saved: SavedAttachment, mimeType: string, size: number): string { + return [ + ...(saved.path === undefined ? [] : [`Original attachment saved at: ${JSON.stringify(saved.path)}`]), + `Attachment reference: ${JSON.stringify(saved.reference)}`, + `Session-relative attachment: ${JSON.stringify(saved.relativePath)}`, + `MIME: ${JSON.stringify(mimeType)}; size: ${String(size)} bytes. Pass the attachment reference to Read or ReadMediaFile in the current session. For other binary formats, Read reports the resolved local path for a converter.`, + ].join('\n'); +} + +async function saveAttachment( + bytes: Uint8Array, + mimeType: string, + store: ISessionMediaStore, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + const mime = mimeType.split(';')[0]!.trim().toLowerCase(); + const hash = createHash('sha256').update(mime).update('\0').update(bytes).digest('hex'); + const ext = mime === 'application/pdf' ? '.pdf' + : mime === 'image/svg+xml' ? (bytes[0] === 0x1f && bytes[1] === 0x8b ? '.svgz' : '.svg') + : textExtensionForMime(mime) ?? mediaExtensionForMime(mime) ?? '.bin'; + const fileId = `f_mcp_${hash}`; + const path = await store.materialize({ + fileId, + size: bytes.length, + name: `attachment${ext}`, + mimeType: mime, + stream: () => Readable.from([bytes]), + signal, + }); + signal?.throwIfAborted(); + return { path, reference: buildDaemonFileUrl(fileId), relativePath: `media/${fileId}${ext}` }; +} + +function parseComparableJson(text: string): unknown { + return JSON.parse(text, (_key: string, value: unknown, context?: { source?: string }) => { + if (typeof value === 'number' && context?.source !== JSON.stringify(value)) { + throw new Error('JSON number cannot be compared without normalization'); + } + return value; + }); +} + +function serializeStructuredExtras(extras: Record): string | undefined { + try { + return JSON.stringify(extras).replaceAll('<', '\\u003c'); + } catch { + return undefined; + } +} + +function stripReservedMetaKeys( + meta: Record, +): Record | undefined { + const out: Record = {}; + for (const [key, value] of Object.entries(meta)) { + if (!isReservedMetaKey(key)) { + out[key] = value; + } + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function isReservedMetaKey(key: string): boolean { + const slash = key.indexOf('/'); + if (slash <= 0) return false; + const labels = key.slice(0, slash).split('.'); + return labels.some( + (label, i) => + (label === 'modelcontextprotocol' || label === 'mcp') && i < labels.length - 1, + ); +} + +function wrapMediaOnly(parts: readonly ContentPart[], qualifiedToolName: string): ContentPart[] { + const hasMedia = parts.some( + (p) => p.type === 'image_url' || p.type === 'audio_url' || p.type === 'video_url', + ); + const hasNonEmptyText = parts.some((p) => p.type === 'text' && p.text.length > 0); + if (!hasMedia || hasNonEmptyText) return [...parts]; + return [ + { type: 'text', text: `` }, + ...parts, + { type: 'text', text: '' }, + ]; +} + +async function applyBinaryPartCap( + parts: readonly ContentPart[], + preserve: (url: string) => Promise, +): Promise<{ + readonly parts: ContentPart[]; + readonly truncated: boolean; + readonly notices: string[]; +}> { + let truncated = false; + const out: ContentPart[] = []; + const notices: string[] = []; + + for (const part of parts) { + if (part.type === 'text' || part.type === 'think') { + out.push(part); + continue; + } + + const url = + part.type === 'image_url' + ? part.imageUrl.url + : part.type === 'audio_url' + ? part.audioUrl.url + : part.videoUrl.url; + if (url.length > MCP_MAX_BINARY_PART_CHARS) { + await preserve(url); + const kind = + part.type === 'image_url' ? 'image' : part.type === 'audio_url' ? 'audio' : 'video'; + const notice = binaryPartTooLargeNotice(kind, url.length); + out.push({ type: 'text', text: notice }); + notices.push(notice); + truncated = true; + continue; + } + out.push(part); + } + + return { parts: out, truncated, notices }; +} + +function collapseSingleText(parts: readonly ContentPart[]): string | ContentPart[] { + if (parts.length === 1 && parts[0]?.type === 'text') { + return parts[0].text; + } + return [...parts]; +} diff --git a/packages/agent-core-v2/src/agent/mcp/tools/auth.ts b/packages/agent-core-v2/src/agent/mcp/tools/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..e65a1584ffc38d37e31dd6d572b9e8a948bca59d --- /dev/null +++ b/packages/agent-core-v2/src/agent/mcp/tools/auth.ts @@ -0,0 +1,150 @@ +import { z } from 'zod'; + +import { + MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE, + type ExecutableTool, + type ExecutableToolContext, + type ExecutableToolResult, +} from '#/tool/toolContract'; +import { toInputJsonSchema } from '#/tool/input-schema'; +import { AlreadyAuthorizedError, type McpOAuthService } from '#/mcpCore/oauth/service'; +import { qualifyMcpToolName } from '#/mcpCore/tool-naming'; + +export { MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE } from '#/tool/toolContract'; + +export interface McpOAuthAuthorizationUrlUpdateData { + readonly serverName: string; + readonly authorizationUrl: string; + readonly expiresAt?: number; +} + +const DEFAULT_AUTH_TIMEOUT_MS = 15 * 60 * 1000; + +const AUTH_TOOL_TOOL_NAME = 'authenticate'; + +const DESCRIPTION_TEMPLATE = (serverName: string): string => + `Authenticate with MCP server "${serverName}" via OAuth. + +This server requires an OAuth login that has not yet been completed. ` + + `Calling this tool starts the authorization flow: + + 1. The tool prints an authorization URL. + 2. **You must show that URL to the user verbatim** and ask them to open it + in a browser, sign in, and approve the client. + 3. The tool blocks (up to 15 minutes) until the browser redirects back to + the local callback listener. + 4. On success, the client reconnects the MCP server and the real tools + replace this synthetic tool. + +Take no arguments. Treat the URL as sensitive — do not modify it or strip +query parameters.`; + +export interface CreateMcpAuthToolOptions { + readonly serverName: string; + readonly serverUrl: string; + readonly oauthService: McpOAuthService; + readonly reconnect: (signal?: AbortSignal) => Promise; + readonly timeoutMs?: number; +} + +export function createMcpAuthTool(options: CreateMcpAuthToolOptions): ExecutableTool { + const { serverName, serverUrl, oauthService, reconnect, timeoutMs } = options; + const name = qualifyMcpToolName(serverName, AUTH_TOOL_TOOL_NAME); + const description = DESCRIPTION_TEMPLATE(serverName); + const parameters = toInputJsonSchema(z.object({})); + const execute = async (ctx: ExecutableToolContext): Promise => { + const { signal, onUpdate } = ctx; + signal.throwIfAborted(); + + onUpdate?.({ kind: 'status', text: `Discovering OAuth metadata for ${serverName}…` }); + + let flow: Awaited>; + try { + flow = await oauthService.beginAuthorization(serverName, serverUrl); + } catch (error) { + if (error instanceof AlreadyAuthorizedError) { + onUpdate?.({ kind: 'status', text: `Already authorized; reconnecting ${serverName}…` }); + try { + await reconnect(signal); + } catch (reconnectError) { + return errorResult(serverName, reconnectError); + } + return { + output: + `MCP server "${serverName}" already had valid OAuth credentials. ` + + `Reconnected; real tools are available now.`, + }; + } + return errorResult(serverName, error); + } + + const urlText = flow.authorizationUrl.toString(); + const waitTimeoutMs = timeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS; + const customData: McpOAuthAuthorizationUrlUpdateData = { + serverName, + authorizationUrl: urlText, + expiresAt: Date.now() + waitTimeoutMs, + }; + onUpdate?.({ + kind: 'custom', + customKind: MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE, + customData, + }); + onUpdate?.({ + kind: 'status', + text: + `Open this URL in your browser to authorize "${serverName}":\n` + + `\n${urlText}\n\n` + + `Waiting for the OAuth callback (timeout 15 min). ` + + `If you cancel, call this tool again to restart the flow.`, + }); + + try { + await flow.complete({ signal, timeoutMs: waitTimeoutMs }); + } catch (error) { + return errorResult(serverName, error, urlText); + } + + onUpdate?.({ kind: 'status', text: `Authorized — reconnecting ${serverName}…` }); + try { + await reconnect(signal); + } catch (error) { + return errorResult(serverName, error); + } + + return { + output: + `MCP server "${serverName}" authenticated successfully. ` + + `The real MCP tools have replaced this synthetic authenticate tool.`, + }; + }; + + return { + name, + description, + parameters, + resolveExecution: () => { + return { + description: `Authenticating ${serverName}`, + approvalRule: name, + execute, + }; + }, + }; +} + +function errorResult( + serverName: string, + error: unknown, + authorizationUrl?: string, +): ExecutableToolResult { + const message = error instanceof Error ? error.message : String(error); + const suffix = + authorizationUrl !== undefined + ? `\n\nAuthorization URL (still valid if the listener has not timed out): ${authorizationUrl}` + : ''; + return { + isError: true, + output: `OAuth flow for MCP server "${serverName}" did not complete: ${message}${suffix}`, + }; +} diff --git a/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts b/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts new file mode 100644 index 0000000000000000000000000000000000000000..e8b2b7ffe7e10a36607f76c54488b2e460769955 --- /dev/null +++ b/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts @@ -0,0 +1,118 @@ +import type { ToolDescription as KosongTool } from '#human/llm/message'; +import type { ITelemetryService } from '#/app/telemetry/telemetry'; +import { Error2, ErrorCodes, toErrorMessage } from '#/errors'; +import { isAbortError } from '#/_base/utils/abort'; + +import type { ExecutableTool, ExecutableToolContext } from '#/tool/toolContract'; +import { mcpResultToExecutableOutput, type McpOutputOptions } from '#/agent/mcp/output'; +import type { MCPClient, MCPToolResult } from '#/mcpCore/types'; +import { + isMcpConnectionClosedError, + isMcpMalformedResultError, + isMcpTransportFailure, + probeMcpLiveness, +} from '#/mcpCore/client-shared'; + +interface McpToolOptions { + readonly attachmentStore?: McpOutputOptions['attachmentStore']; + readonly originalsDir?: string; + readonly telemetry?: ITelemetryService; + readonly providerType?: () => string | undefined; + readonly reconnect?: (signal?: AbortSignal) => Promise; + readonly isRemoved?: () => boolean; +} + +export function createMcpTool( + qualifiedName: string, + tool: KosongTool, + client: MCPClient, + options: McpToolOptions = {}, +): ExecutableTool { + const callTool = (activeClient: MCPClient, args: unknown, signal: AbortSignal) => + activeClient.callTool(tool.name, (args ?? {}) as Record, signal); + return { + name: qualifiedName, + description: tool.description, + parameters: tool.parameters, + resolveExecution: (args) => ({ + approvalRule: qualifiedName, + execute: async (context) => { + if (options.isRemoved?.() === true) { + return { + output: + `MCP server for tool "${qualifiedName}" has been removed ` + + `(plugin uninstalled or config deleted). Do not call this tool again.`, + isError: true, + }; + } + let result; + try { + result = await callTool(client, args, context.signal); + } catch (error) { + result = await retryAfterReconnect(error, client, args, context, options, callTool); + } + return mcpResultToExecutableOutput(result, qualifiedName, { + signal: context.signal, + attachmentStore: options.attachmentStore, + originalsDir: options.originalsDir, + telemetry: options.telemetry, + providerType: options.providerType?.(), + }); + }, + }), + }; +} + +async function retryAfterReconnect( + error: unknown, + client: MCPClient, + args: unknown, + context: Pick, + options: McpToolOptions, + callTool: (client: MCPClient, args: unknown, signal: AbortSignal) => Promise, +): Promise { + const reconnect = options.reconnect; + const isUnrecoverable = (e: unknown): boolean => + context.signal.aborted || + isAbortError(e) || + !isMcpTransportFailure(e) || + isMcpMalformedResultError(e); + if (reconnect === undefined || isUnrecoverable(error)) { + throw error; + } + + let failure = error; + if (!isMcpConnectionClosedError(failure)) { + const alive = await probeMcpLiveness(client, context.signal); + context.signal.throwIfAborted(); + if (alive) { + try { + return await callTool(client, args, context.signal); + } catch (retryError) { + if (isUnrecoverable(retryError)) { + throw retryError; + } + failure = retryError; + } + } + } + + context.onUpdate?.({ kind: 'status', text: 'MCP connection lost — reconnecting…' }); + let freshClient: MCPClient | undefined; + try { + freshClient = await reconnect(context.signal); + } catch (reconnectError) { + if (context.signal.aborted || isAbortError(reconnectError)) { + throw reconnectError; + } + throw new Error2( + ErrorCodes.MCP_STARTUP_FAILED, + `${toErrorMessage(failure)} (reconnecting the MCP server also failed: ${toErrorMessage(reconnectError)})`, + { cause: reconnectError }, + ); + } + if (freshClient === undefined) { + throw failure; + } + return callTool(freshClient, args, context.signal); +} diff --git a/packages/agent-core-v2/src/agent/media/configSection.ts b/packages/agent-core-v2/src/agent/media/configSection.ts new file mode 100644 index 0000000000000000000000000000000000000000..ef9c75bfbd14c546a7e30ae191bed86f01668e44 --- /dev/null +++ b/packages/agent-core-v2/src/agent/media/configSection.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +import { type EnvBindings, envBindings, stripEnvBoundFields } from '#/app/config/config'; +import { registerConfigSection } from '#/app/config/configSectionContributions'; + +export const IMAGE_SECTION = 'image'; + +export const IMAGE_MAX_EDGE_ENV = 'KIMI_IMAGE_MAX_EDGE_PX'; +export const IMAGE_READ_BYTE_BUDGET_ENV = 'KIMI_IMAGE_READ_BYTE_BUDGET'; + +export const ImageConfigSchema = z.object({ + maxEdgePx: z.number().int().min(1).optional(), + readByteBudget: z.number().int().min(1).optional(), +}); + +export type ImageConfig = z.infer; + +function parsePositiveInt(raw: string): number | undefined { + const value = raw.trim(); + if (value.length === 0 || !/^\d+$/.test(value)) return undefined; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; +} + +export const imageEnvBindings: EnvBindings = envBindings(ImageConfigSchema, { + maxEdgePx: { env: IMAGE_MAX_EDGE_ENV, parse: parsePositiveInt }, + readByteBudget: { env: IMAGE_READ_BYTE_BUDGET_ENV, parse: parsePositiveInt }, +}); + +export const stripImageEnv = stripEnvBoundFields(imageEnvBindings); + +registerConfigSection(IMAGE_SECTION, ImageConfigSchema, { + defaultValue: {}, + env: imageEnvBindings, + stripEnv: stripImageEnv, +}); diff --git a/packages/agent-core-v2/src/agent/media/file-type.ts b/packages/agent-core-v2/src/agent/media/file-type.ts new file mode 100644 index 0000000000000000000000000000000000000000..d729c68231c4b987cbbf31be0ef769e0ff4a70b5 --- /dev/null +++ b/packages/agent-core-v2/src/agent/media/file-type.ts @@ -0,0 +1,368 @@ +import { + AUDIO_MIME_BY_SUFFIX, + IMAGE_MIME_BY_SUFFIX, + VIDEO_MIME_BY_SUFFIX, +} from './mediaRef'; + +export { AUDIO_MIME_BY_SUFFIX, IMAGE_MIME_BY_SUFFIX, VIDEO_MIME_BY_SUFFIX }; + +export const MEDIA_SNIFF_BYTES = 512; + +export interface FileType { + readonly kind: 'text' | 'image' | 'video' | 'unknown'; + readonly mimeType: string; +} + +export type DetectFileTypeMode = 'text' | 'media'; + +const TEXT_MIME_BY_SUFFIX: Readonly> = Object.freeze({ + '.svg': 'image/svg+xml', +}); + +export const NON_TEXT_SUFFIXES: ReadonlySet = new Set([ + '.icns', + '.psd', + '.ai', + '.eps', + '.pdf', + '.doc', + '.docx', + '.dot', + '.dotx', + '.rtf', + '.odt', + '.xls', + '.xlsx', + '.xlsm', + '.xlt', + '.xltx', + '.xltm', + '.ods', + '.ppt', + '.pptx', + '.pptm', + '.pps', + '.ppsx', + '.odp', + '.pages', + '.numbers', + '.key', + '.zip', + '.rar', + '.7z', + '.tar', + '.gz', + '.tgz', + '.bz2', + '.xz', + '.zst', + '.lz', + '.lz4', + '.br', + '.cab', + '.ar', + '.deb', + '.rpm', + '.mp3', + '.wav', + '.flac', + '.ogg', + '.oga', + '.opus', + '.aac', + '.m4a', + '.wma', + '.ttf', + '.otf', + '.woff', + '.woff2', + '.exe', + '.dll', + '.so', + '.dylib', + '.bin', + '.apk', + '.ipa', + '.jar', + '.class', + '.pyc', + '.pyo', + '.wasm', + '.dmg', + '.iso', + '.img', + '.sqlite', + '.sqlite3', + '.db', + '.db3', +]); + +const ASF_HEADER = Buffer.from([ + 0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11, 0xa6, 0xd9, 0x00, 0xaa, 0x00, 0x62, 0xce, 0x6c, +]); + +const FTYP_IMAGE_BRANDS: Readonly> = Object.freeze({ + avif: 'image/avif', + avis: 'image/avif', + heic: 'image/heic', + heif: 'image/heif', + heix: 'image/heif', + hevc: 'image/heic', + mif1: 'image/heif', + msf1: 'image/heif', +}); + +const FTYP_VIDEO_BRANDS: Readonly> = Object.freeze({ + isom: 'video/mp4', + iso2: 'video/mp4', + iso5: 'video/mp4', + mp41: 'video/mp4', + mp42: 'video/mp4', + avc1: 'video/mp4', + mp4v: 'video/mp4', + m4v: 'video/x-m4v', + qt: 'video/quicktime', + '3gp4': 'video/3gpp', + '3gp5': 'video/3gpp', + '3gp6': 'video/3gpp', + '3gp7': 'video/3gpp', + '3g2': 'video/3gpp2', +}); + +function toBuffer(data: Buffer | Uint8Array): Buffer { + return Buffer.isBuffer(data) ? data : Buffer.from(data.buffer, data.byteOffset, data.byteLength); +} + +function startsWith(buf: Buffer, prefix: Buffer | readonly number[]): boolean { + const needle = Buffer.isBuffer(prefix) ? prefix : Buffer.from(prefix); + if (buf.length < needle.length) return false; + for (let i = 0; i < needle.length; i += 1) { + if (buf[i] !== needle[i]) return false; + } + return true; +} + +function sniffFtypBrand(header: Buffer): string | null { + if (header.length < 12) return null; + if (header.subarray(4, 8).toString('latin1') !== 'ftyp') return null; + const raw = header.subarray(8, 12).toString('latin1').toLowerCase(); + return raw.replaceAll(/[\s\u0000]+$/g, '').trim(); +} + +export function sniffMediaFromMagic(data: Buffer | Uint8Array): FileType | null { + const buf = toBuffer(data); + const header = buf.length > MEDIA_SNIFF_BYTES ? buf.subarray(0, MEDIA_SNIFF_BYTES) : buf; + + if (startsWith(header, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { + return { kind: 'image', mimeType: 'image/png' }; + } + if (startsWith(header, [0xff, 0xd8, 0xff])) { + return { kind: 'image', mimeType: 'image/jpeg' }; + } + if (startsWith(header, Buffer.from('GIF87a')) || startsWith(header, Buffer.from('GIF89a'))) { + return { kind: 'image', mimeType: 'image/gif' }; + } + if (startsWith(header, Buffer.from('BM'))) { + return { kind: 'image', mimeType: 'image/bmp' }; + } + if ( + startsWith(header, [0x49, 0x49, 0x2a, 0x00]) || + startsWith(header, [0x4d, 0x4d, 0x00, 0x2a]) + ) { + return { kind: 'image', mimeType: 'image/tiff' }; + } + if (startsWith(header, [0x00, 0x00, 0x01, 0x00])) { + return { kind: 'image', mimeType: 'image/x-icon' }; + } + if (startsWith(header, Buffer.from('RIFF')) && header.length >= 12) { + const chunk = header.subarray(8, 12).toString('latin1'); + if (chunk === 'WEBP') return { kind: 'image', mimeType: 'image/webp' }; + if (chunk === 'AVI ') return { kind: 'video', mimeType: 'video/x-msvideo' }; + } + if (startsWith(header, Buffer.from('FLV'))) { + return { kind: 'video', mimeType: 'video/x-flv' }; + } + if (startsWith(header, ASF_HEADER)) { + return { kind: 'video', mimeType: 'video/x-ms-wmv' }; + } + if (startsWith(header, [0x1a, 0x45, 0xdf, 0xa3])) { + const lowered = header.toString('latin1').toLowerCase(); + if (lowered.includes('webm')) return { kind: 'video', mimeType: 'video/webm' }; + if (lowered.includes('matroska')) return { kind: 'video', mimeType: 'video/x-matroska' }; + } + const brand = sniffFtypBrand(header); + if (brand !== null && brand !== '') { + if (brand in FTYP_IMAGE_BRANDS) { + return { kind: 'image', mimeType: FTYP_IMAGE_BRANDS[brand]! }; + } + if (brand in FTYP_VIDEO_BRANDS) { + return { kind: 'video', mimeType: FTYP_VIDEO_BRANDS[brand]! }; + } + } + return null; +} + +export interface ImageDimensions { + readonly width: number; + readonly height: number; + readonly transposed?: boolean; +} + +export function sniffImageDimensions(data: Buffer | Uint8Array): ImageDimensions | null { + const buf = toBuffer(data); + + if (startsWith(buf, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) && buf.length >= 24) { + return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }; + } + + if ( + (startsWith(buf, Buffer.from('GIF87a')) || startsWith(buf, Buffer.from('GIF89a'))) && + buf.length >= 10 + ) { + return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) }; + } + + if (startsWith(buf, Buffer.from('BM')) && buf.length >= 26) { + return { width: buf.readInt32LE(18), height: Math.abs(buf.readInt32LE(22)) }; + } + + if (startsWith(buf, Buffer.from('RIFF')) && buf.length >= 30) { + const fourCc = buf.subarray(12, 16).toString('latin1'); + if (fourCc === 'VP8 ') { + return { + width: buf.readUInt16LE(26) & 0x3fff, + height: buf.readUInt16LE(28) & 0x3fff, + }; + } + if (fourCc === 'VP8L' && buf.length >= 25) { + const bits = buf.readUInt32LE(21); + return { + width: (bits & 0x3fff) + 1, + height: ((bits >> 14) & 0x3fff) + 1, + }; + } + if (fourCc === 'VP8X') { + const width = 1 + (buf[24]! | (buf[25]! << 8) | (buf[26]! << 16)); + const height = 1 + (buf[27]! | (buf[28]! << 8) | (buf[29]! << 16)); + return { width, height }; + } + } + + if (startsWith(buf, [0xff, 0xd8])) { + let orientation: number | null = null; + let offset = 2; + while (offset + 9 < buf.length) { + if (buf[offset] !== 0xff) { + offset += 1; + continue; + } + const marker = buf[offset + 1]!; + if ( + marker >= 0xc0 && + marker <= 0xcf && + marker !== 0xc4 && + marker !== 0xc8 && + marker !== 0xcc + ) { + const height = buf.readUInt16BE(offset + 5); + const width = buf.readUInt16BE(offset + 7); + return orientation !== null && orientation >= 5 + ? { width: height, height: width, transposed: true } + : { width, height }; + } + if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) { + offset += 2; + continue; + } + const segmentLength = buf.readUInt16BE(offset + 2); + if (segmentLength < 2) break; + if (marker === 0xe1 && orientation === null) { + orientation = readExifOrientation(buf, offset + 4, offset + 2 + segmentLength); + } + offset += 2 + segmentLength; + } + } + + return null; +} + +function readExifOrientation(buf: Buffer, start: number, end: number): number | null { + const boundedEnd = Math.min(end, buf.length); + if (start + 6 > boundedEnd || buf.toString('latin1', start, start + 6) !== 'Exif\0\0') { + return null; + } + const tiff = start + 6; + if (tiff + 8 > boundedEnd) return null; + const byteOrder = buf.toString('latin1', tiff, tiff + 2); + const le = byteOrder === 'II'; + if (!le && byteOrder !== 'MM') return null; + const u16 = (offset: number): number => (le ? buf.readUInt16LE(offset) : buf.readUInt16BE(offset)); + const u32 = (offset: number): number => (le ? buf.readUInt32LE(offset) : buf.readUInt32BE(offset)); + if (u16(tiff + 2) !== 42) return null; + const ifd = tiff + u32(tiff + 4); + if (ifd + 2 > boundedEnd) return null; + const entryCount = u16(ifd); + for (let i = 0; i < entryCount; i += 1) { + const entry = ifd + 2 + i * 12; + if (entry + 12 > boundedEnd) return null; + if (u16(entry) === 0x0112) { + const value = u16(entry + 8); + return value >= 1 && value <= 8 ? value : null; + } + } + return null; +} + +function getSuffix(path: string): string { + const idx = path.lastIndexOf('.'); + if (idx === -1) return ''; + const lastSep = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + if (idx <= lastSep + 1) return ''; + return path.slice(idx).toLowerCase(); +} + +export function detectFileType( + path: string, + header?: Buffer | Uint8Array, + type: DetectFileTypeMode = 'text', +): FileType { + const suffix = getSuffix(path); + let mediaHint: FileType | null = null; + if (suffix in TEXT_MIME_BY_SUFFIX) { + mediaHint = { kind: 'text', mimeType: TEXT_MIME_BY_SUFFIX[suffix]! }; + } else if (suffix in IMAGE_MIME_BY_SUFFIX) { + mediaHint = { kind: 'image', mimeType: IMAGE_MIME_BY_SUFFIX[suffix]! }; + } else if (suffix in VIDEO_MIME_BY_SUFFIX) { + mediaHint = { kind: 'video', mimeType: VIDEO_MIME_BY_SUFFIX[suffix]! }; + } + + if (header !== undefined) { + const buf = toBuffer(header); + const sniffed = sniffMediaFromMagic(buf); + if (sniffed) { + if (type === 'media') return sniffed; + if (mediaHint) { + if (sniffed.kind !== mediaHint.kind) { + return { kind: 'unknown', mimeType: '' }; + } + return mediaHint; + } + return sniffed; + } + if (mediaHint?.kind === 'image') { + return { kind: 'unknown', mimeType: '' }; + } + if (type === 'media' && mediaHint?.kind === 'video') { + return mediaHint; + } + if (buf.includes(0x00)) { + return { kind: 'unknown', mimeType: '' }; + } + } + + if (mediaHint) return mediaHint; + if (NON_TEXT_SUFFIXES.has(suffix)) { + return { kind: 'unknown', mimeType: '' }; + } + return { kind: 'text', mimeType: 'text/plain' }; +} diff --git a/packages/agent-core-v2/src/agent/media/image-compress.ts b/packages/agent-core-v2/src/agent/media/image-compress.ts new file mode 100644 index 0000000000000000000000000000000000000000..0c877306f5049e35dab276da9bec43e5601dc7e8 --- /dev/null +++ b/packages/agent-core-v2/src/agent/media/image-compress.ts @@ -0,0 +1,795 @@ +import type { ContentPart } from '#human/llm/message'; +import type { ImageCompressEvent, ImageCropEvent } from '#/app/telemetry/events'; +import type { ITelemetryService } from '#/app/telemetry/telemetry'; +import { DEFAULT_INLINE_IMAGE_BYTE_BUDGET } from '#human/llm/media/image-formats'; + +import { sniffImageDimensions } from './file-type'; +import { + buildMalformedImageNotice, + buildUnsupportedImageNotice, + decodeBase64Prefix, + isDataUrl, + isModelAcceptedImageMime, + normalizeImageMime, + parseImageDataUrl, + resolveEffectiveImageMime, + unsupportedImageMimeFromUrl, +} from './image-format-policy'; +import { decodeWebp, isAnimatedWebp } from './webp-decode'; + +export const MAX_IMAGE_EDGE_PX = 2000; + +let configuredMaxImageEdgePx: number | undefined; + +export function setConfiguredMaxImageEdgePx(value: number | undefined): void { + configuredMaxImageEdgePx = value !== undefined && isPositiveInt(value) ? value : undefined; +} + +export function resolveMaxImageEdgePx(): number { + return configuredMaxImageEdgePx ?? MAX_IMAGE_EDGE_PX; +} + +export const IMAGE_BYTE_BUDGET = DEFAULT_INLINE_IMAGE_BYTE_BUDGET; + +export const READ_IMAGE_BYTE_BUDGET = 256 * 1024; + +let configuredReadImageByteBudget: number | undefined; + +export function setConfiguredReadImageByteBudget(value: number | undefined): void { + configuredReadImageByteBudget = + value !== undefined && isPositiveInt(value) ? value : undefined; +} + +export function resolveReadImageByteBudget(): number { + return configuredReadImageByteBudget ?? READ_IMAGE_BYTE_BUDGET; +} + +function isPositiveInt(value: number): boolean { + return Number.isInteger(value) && value > 0; +} +const JPEG_QUALITY_STEPS = [80, 60, 40, 20] as const; + +const FALLBACK_EDGES_PX = [2000, 1000, 768, 512, 384, 256] as const; + +const PNG_RESCALE_FLOOR_PX = 1000; + +const MAX_DECODE_PIXELS = 100_000_000; + +export const MAX_IMAGE_DECODE_BYTES = 64 * 1024 * 1024; + +const RECODABLE_MIME = new Set(['image/png', 'image/jpeg', 'image/webp']); + +export function isRecodableImage(bytes: Uint8Array, mimeType: string): boolean { + const normalizedMime = normalizeImageMime(mimeType); + if (!RECODABLE_MIME.has(normalizedMime)) return false; + return normalizedMime !== 'image/webp' || !isAnimatedWebp(bytes); +} + +export interface CompressImageOptions { + readonly maxEdge?: number; + readonly byteBudget?: number; + readonly maxDecodeBytes?: number; + readonly telemetry?: ITelemetryService; + readonly telemetrySource?: string; +} + +type CompressOutcome = + | 'compressed' + | 'passthrough_fast' + | 'passthrough_guard' + | 'passthrough_unsupported' + | 'passthrough_unhelpful' + | 'passthrough_error'; + +export interface CompressImageResult { + readonly data: Uint8Array; + readonly mimeType: string; + readonly width: number; + readonly height: number; + readonly originalWidth: number; + readonly originalHeight: number; + readonly changed: boolean; + readonly originalByteLength: number; + readonly finalByteLength: number; +} + +export async function compressImageForModel( + bytes: Uint8Array, + mimeType: string, + options: CompressImageOptions = {}, +): Promise { + const startedAt = Date.now(); + const maxEdge = options.maxEdge ?? resolveMaxImageEdgePx(); + const byteBudget = options.byteBudget ?? IMAGE_BYTE_BUDGET; + const maxDecodeBytes = options.maxDecodeBytes ?? MAX_IMAGE_DECODE_BYTES; + const normalizedMime = normalizeImageMime(mimeType); + const dims = sniffImageDimensions(bytes); + + const passthrough = (): CompressImageResult => ({ + data: bytes, + mimeType, + width: dims?.width ?? 0, + height: dims?.height ?? 0, + originalWidth: dims?.width ?? 0, + originalHeight: dims?.height ?? 0, + changed: false, + originalByteLength: bytes.length, + finalByteLength: bytes.length, + }); + const finish = (outcome: CompressOutcome, result: CompressImageResult): CompressImageResult => { + reportCompressEvent(options.telemetry, options.telemetrySource, { + outcome, + startedAt, + inputMime: normalizedMime, + exifTransposed: dims?.transposed === true, + result, + }); + return result; + }; + + if (bytes.length === 0 || !isRecodableImage(bytes, normalizedMime)) { + return finish('passthrough_unsupported', passthrough()); + } + + const longestEdge = dims ? Math.max(dims.width, dims.height) : 0; + const withinBytes = bytes.length <= byteBudget; + const withinEdge = longestEdge > 0 && longestEdge <= maxEdge; + if (withinBytes && (withinEdge || longestEdge === 0)) { + return finish('passthrough_fast', passthrough()); + } + + if (dims && dims.width * dims.height > MAX_DECODE_PIXELS) { + return finish('passthrough_guard', passthrough()); + } + if (bytes.length > maxDecodeBytes) return finish('passthrough_guard', passthrough()); + + try { + const image = await decodeToJimp(bytes, normalizedMime); + const preferLossless = normalizedMime !== 'image/jpeg'; + const decodedWidth = image.width; + const decodedHeight = image.height; + + fitWithinEdge(image, maxEdge); + + const encoded = await encodeWithinBudget(image, { + preferLossless, + byteBudget, + fallbackEdges: FALLBACK_EDGES_PX, + }); + + const originalPixels = decodedWidth * decodedHeight; + const finalPixels = encoded.width * encoded.height; + const shrankBytes = encoded.data.length < bytes.length; + const shrankPixels = finalPixels < originalPixels; + if (!shrankBytes && !shrankPixels) return finish('passthrough_unhelpful', passthrough()); + + return finish('compressed', { + data: encoded.data, + mimeType: encoded.mimeType, + width: encoded.width, + height: encoded.height, + originalWidth: decodedWidth, + originalHeight: decodedHeight, + changed: true, + originalByteLength: bytes.length, + finalByteLength: encoded.data.length, + }); + } catch { + return finish('passthrough_error', passthrough()); + } +} + +export interface CompressBase64Result { + readonly base64: string; + readonly mimeType: string; + readonly width: number; + readonly height: number; + readonly originalWidth: number; + readonly originalHeight: number; + readonly changed: boolean; + readonly originalByteLength: number; + readonly finalByteLength: number; +} + +export async function compressBase64ForModel( + base64: string, + mimeType: string, + options: CompressImageOptions = {}, +): Promise { + const startedAt = Date.now(); + const maxDecodeBytes = options.maxDecodeBytes ?? MAX_IMAGE_DECODE_BYTES; + const approxBytes = Math.floor((base64.length * 3) / 4); + if (approxBytes > maxDecodeBytes) { + const result: CompressBase64Result = { + base64, + mimeType, + width: 0, + height: 0, + originalWidth: 0, + originalHeight: 0, + changed: false, + originalByteLength: approxBytes, + finalByteLength: approxBytes, + }; + reportCompressEvent(options.telemetry, options.telemetrySource, { + outcome: 'passthrough_guard', + startedAt, + inputMime: normalizeImageMime(mimeType), + exifTransposed: false, + result, + }); + return result; + } + let bytes: Buffer; + try { + bytes = Buffer.from(base64, 'base64'); + } catch { + const result: CompressBase64Result = { + base64, + mimeType, + width: 0, + height: 0, + originalWidth: 0, + originalHeight: 0, + changed: false, + originalByteLength: 0, + finalByteLength: 0, + }; + reportCompressEvent(options.telemetry, options.telemetrySource, { + outcome: 'passthrough_error', + startedAt, + inputMime: normalizeImageMime(mimeType), + exifTransposed: false, + result, + }); + return result; + } + const result = await compressImageForModel(bytes, mimeType, options); + if (!result.changed) { + return { + base64, + mimeType, + width: result.width, + height: result.height, + originalWidth: result.originalWidth, + originalHeight: result.originalHeight, + changed: false, + originalByteLength: result.originalByteLength, + finalByteLength: result.finalByteLength, + }; + } + return { + base64: Buffer.from(result.data).toString('base64'), + mimeType: result.mimeType, + width: result.width, + height: result.height, + originalWidth: result.originalWidth, + originalHeight: result.originalHeight, + changed: true, + originalByteLength: result.originalByteLength, + finalByteLength: result.finalByteLength, + }; +} + +export interface CompressedContentParts { + readonly parts: ContentPart[]; + readonly captions: readonly string[]; +} + +export function gateImageFormatParts( + parts: readonly ContentPart[], + providerType?: string, +): ContentPart[] { + const out: ContentPart[] = []; + for (const part of parts) { + if (part.type === 'image_url') { + const parsed = parseImageDataUrl(part.imageUrl.url); + if (parsed === null) { + if (isDataUrl(part.imageUrl.url)) { + out.push({ type: 'text', text: buildMalformedImageNotice(part.imageUrl.url) }); + continue; + } + const extMime = unsupportedImageMimeFromUrl(part.imageUrl.url, providerType); + if (extMime !== null) { + out.push({ + type: 'text', + text: buildUnsupportedImageNotice(extMime, part.imageUrl.url, providerType), + }); + continue; + } + out.push(part); + continue; + } + const effectiveMime = resolveEffectiveImageMime( + parsed.mimeType, + decodeBase64Prefix(parsed.base64), + ); + if (!isModelAcceptedImageMime(effectiveMime, providerType)) { + out.push({ + type: 'text', + text: buildUnsupportedImageNotice(effectiveMime, undefined, providerType), + }); + continue; + } + const canonicalUrl = `data:${normalizeImageMime(effectiveMime)};base64,${parsed.base64}`; + if (part.imageUrl.url !== canonicalUrl) { + out.push({ type: 'image_url', imageUrl: { ...part.imageUrl, url: canonicalUrl } }); + continue; + } + } + out.push(part); + } + return out; +} + +export async function compressImageContentParts( + parts: readonly ContentPart[], + options: CompressImageOptions & { + readonly annotate?: CompressAnnotateOptions; + readonly providerType?: string; + readonly signal?: AbortSignal; + } = {}, +): Promise { + const { annotate, providerType, signal, ...compressOptions } = options; + signal?.throwIfAborted(); + const out: ContentPart[] = []; + const captions: string[] = []; + for (const part of gateImageFormatParts(parts, providerType)) { + signal?.throwIfAborted(); + if (part.type === 'image_url') { + const parsed = parseImageDataUrl(part.imageUrl.url); + if (parsed !== null) { + const result = await compressBase64ForModel(parsed.base64, parsed.mimeType, compressOptions); + signal?.throwIfAborted(); + if (result.changed) { + if (annotate !== undefined) { + let originalPath: string | null = null; + if (annotate.persistOriginal !== undefined) { + try { + originalPath = await annotate.persistOriginal( + Buffer.from(parsed.base64, 'base64'), + parsed.mimeType, + ); + } catch { + signal?.throwIfAborted(); + originalPath = null; + } + } + captions.push( + buildImageCompressionCaption({ + original: { + width: result.originalWidth, + height: result.originalHeight, + byteLength: result.originalByteLength, + mimeType: parsed.mimeType, + }, + final: { + width: result.width, + height: result.height, + byteLength: result.finalByteLength, + mimeType: result.mimeType, + }, + originalPath, + }), + ); + } + out.push({ + type: 'image_url', + imageUrl: { ...part.imageUrl, url: `data:${result.mimeType};base64,${result.base64}` }, + }); + continue; + } + } + } + out.push(part); + } + return { parts: out, captions }; +} + +export interface CompressAnnotateOptions { + readonly persistOriginal?: (bytes: Uint8Array, mimeType: string) => Promise; +} + +export interface ImageCropRegion { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +export interface CropImageOptions extends CompressImageOptions { + readonly skipResize?: boolean; +} + +export interface CropImageSuccess { + readonly ok: true; + readonly data: Uint8Array; + readonly mimeType: string; + readonly width: number; + readonly height: number; + readonly originalWidth: number; + readonly originalHeight: number; + readonly region: ImageCropRegion; + readonly resized: boolean; + readonly originalByteLength: number; + readonly finalByteLength: number; +} + +export interface CropImageFailure { + readonly ok: false; + readonly error: string; +} + +export type CropImageOutcome = CropImageSuccess | CropImageFailure; + +export async function cropImageForModel( + bytes: Uint8Array, + mimeType: string, + region: ImageCropRegion, + options: CropImageOptions = {}, +): Promise { + const startedAt = Date.now(); + const maxEdge = options.maxEdge ?? resolveMaxImageEdgePx(); + const byteBudget = options.byteBudget ?? IMAGE_BYTE_BUDGET; + const maxDecodeBytes = options.maxDecodeBytes ?? MAX_IMAGE_DECODE_BYTES; + const normalizedMime = normalizeImageMime(mimeType); + + const fail = (errorKind: CropErrorKind, error: string): CropImageFailure => { + reportCropEvent(options.telemetry, options.telemetrySource, { startedAt, ok: false, errorKind }); + return { ok: false, error }; + }; + const succeed = (result: CropImageSuccess): CropImageSuccess => { + reportCropEvent(options.telemetry, options.telemetrySource, { startedAt, ok: true, result }); + return result; + }; + + if (bytes.length === 0) { + return fail('empty', 'The image is empty.'); + } + if (!RECODABLE_MIME.has(normalizedMime)) { + return fail( + 'unsupported_format', + `Cropping is only supported for PNG, JPEG, and WebP images; got ${mimeType}.`, + ); + } + if (normalizedMime === 'image/webp' && isAnimatedWebp(bytes)) { + return fail('unsupported_format', 'Cropping is not supported for animated WebP images.'); + } + if ( + ![region.x, region.y, region.width, region.height].every((value) => Number.isFinite(value)) + ) { + return fail( + 'region_invalid', + `Region coordinates must be finite numbers; got x=${String(region.x)}, ` + + `y=${String(region.y)}, width=${String(region.width)}, height=${String(region.height)}.`, + ); + } + const dims = sniffImageDimensions(bytes); + if (dims && dims.width * dims.height > MAX_DECODE_PIXELS) { + return fail( + 'too_large', + `The image (${String(dims.width)}x${String(dims.height)} pixels) is too large to decode for cropping.`, + ); + } + if (bytes.length > maxDecodeBytes) { + return fail('too_large', 'The image is too large to decode for cropping.'); + } + + try { + const image = await decodeToJimp(bytes, normalizedMime); + const originalWidth = image.width; + const originalHeight = image.height; + + const x = Math.floor(region.x); + const y = Math.floor(region.y); + if (x < 0 || y < 0 || x >= originalWidth || y >= originalHeight || region.width < 1 || region.height < 1) { + return fail( + 'out_of_bounds', + `Region (x=${String(region.x)}, y=${String(region.y)}, width=${String(region.width)}, ` + + `height=${String(region.height)}) lies outside the ${String(originalWidth)}x${String(originalHeight)} image.`, + ); + } + const w = Math.min(Math.floor(region.width), originalWidth - x); + const h = Math.min(Math.floor(region.height), originalHeight - y); + const applied: ImageCropRegion = { x, y, width: w, height: h }; + image.crop({ x, y, w, h }); + const preferLossless = normalizedMime !== 'image/jpeg'; + + if (options.skipResize === true) { + const buffer = preferLossless + ? await image.getBuffer('image/png', { deflateLevel: 9 }) + : await image.getBuffer('image/jpeg', { quality: 90 }); + if (buffer.length > byteBudget) { + return fail( + 'budget', + `The cropped region encodes to ${String(buffer.length)} bytes ` + + `(${formatByteSize(buffer.length)}), over the ${String(byteBudget)}-byte ` + + `(${formatByteSize(byteBudget)}) per-image limit. ` + + 'Choose a smaller region, or allow downscaling.', + ); + } + return succeed({ + ok: true, + data: new Uint8Array(buffer), + mimeType: preferLossless ? 'image/png' : 'image/jpeg', + width: image.width, + height: image.height, + originalWidth, + originalHeight, + region: applied, + resized: false, + originalByteLength: bytes.length, + finalByteLength: buffer.length, + }); + } + + fitWithinEdge(image, maxEdge); + const encoded = await encodeWithinBudget(image, { + preferLossless, + byteBudget, + fallbackEdges: FALLBACK_EDGES_PX, + }); + return succeed({ + ok: true, + data: new Uint8Array(encoded.data), + mimeType: encoded.mimeType, + width: encoded.width, + height: encoded.height, + originalWidth, + originalHeight, + region: applied, + resized: encoded.width !== w || encoded.height !== h, + originalByteLength: bytes.length, + finalByteLength: encoded.data.length, + }); + } catch (error) { + return fail( + 'decode_failed', + `Failed to decode the image for cropping: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +export interface ImageVariantDescription { + readonly width: number; + readonly height: number; + readonly byteLength: number; + readonly mimeType: string; +} + +export interface ImageCompressionCaptionInput { + readonly original: ImageVariantDescription; + readonly final: ImageVariantDescription; + readonly originalPath?: string | null; +} + +export function buildImageCompressionCaption(input: ImageCompressionCaptionInput): string { + const sentences = [ + `Image compressed to fit model limits: original ${describeImageVariant(input.original)} -> ` + + `sent ${describeImageVariant(input.final)}.`, + 'Fine detail may be lost.', + ]; + if (typeof input.originalPath === 'string' && input.originalPath.length > 0) { + sentences.push( + `The uncompressed original is saved at "${input.originalPath}"; if you need fine detail ` + + '(e.g. small text), call ReadMediaFile on that path with the region parameter ' + + '(original-pixel coordinates) to view a crop at full fidelity.', + ); + } else { + sentences.push('The uncompressed original was not preserved.'); + } + return `${sentences.join(' ')}`; +} + +const CAPTION_OPENING = 'Image compressed to fit model limits:'; + +const CAPTION_PATTERN = /(Image compressed to fit model limits:[\s\S]*?)<\/system>/g; + +export interface ImageCompressionCaptionExtraction { + readonly captions: readonly string[]; + readonly text: string; +} + +export function extractImageCompressionCaptions(text: string): ImageCompressionCaptionExtraction { + if (!text.includes(CAPTION_OPENING)) return { captions: [], text }; + const captions: string[] = []; + const remainder = text.replace(CAPTION_PATTERN, (_match, body: string) => { + captions.push(body); + return ''; + }); + return { captions, text: remainder }; +} + +function describeImageVariant(variant: ImageVariantDescription): string { + const size = `${variant.mimeType} (${formatByteSize(variant.byteLength)})`; + if (variant.width > 0 && variant.height > 0) { + return `${String(variant.width)}x${String(variant.height)} ${size}`; + } + return size; +} + +export function formatByteSize(bytes: number): string { + if (bytes < 1024) return `${String(bytes)} B`; + if (bytes < 1024 * 1024) return `${String(Math.round(bytes / 1024))} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +type JimpImage = Awaited>; + +interface EncodedImage { + readonly data: Buffer; + readonly mimeType: string; + readonly width: number; + readonly height: number; +} + +interface EncodeOptions { + readonly preferLossless: boolean; + readonly byteBudget: number; + readonly fallbackEdges: readonly number[]; +} + +async function decodeToJimp(bytes: Uint8Array, normalizedMime: string): Promise { + const { Jimp } = await import('jimp'); + if (normalizedMime === 'image/webp') { + const decoded = await decodeWebp(bytes); + return Jimp.fromBitmap({ + data: Buffer.from(decoded.data.buffer, decoded.data.byteOffset, decoded.data.byteLength), + width: decoded.width, + height: decoded.height, + }); + } + return Jimp.fromBuffer(Buffer.from(bytes)); +} + +async function encodeWithinBudget(image: JimpImage, opts: EncodeOptions): Promise { + const { preferLossless, byteBudget, fallbackEdges } = opts; + let smallest: EncodedImage | null = null; + + const consider = (data: Buffer, mimeType: string): EncodedImage => { + const candidate: EncodedImage = { data, mimeType, width: image.width, height: image.height }; + if (smallest === null || candidate.data.length < smallest.data.length) { + smallest = candidate; + } + return candidate; + }; + + const jpegLadder = async (): Promise => { + for (const quality of JPEG_QUALITY_STEPS) { + const jpeg = await image.getBuffer('image/jpeg', { quality }); + if (jpeg.length <= byteBudget) return consider(jpeg, 'image/jpeg'); + consider(jpeg, 'image/jpeg'); + } + return null; + }; + + if (preferLossless) { + const png = await image.getBuffer('image/png', { deflateLevel: 9 }); + if (png.length <= byteBudget) return consider(png, 'image/png'); + consider(png, 'image/png'); + + for (const edge of fallbackEdges) { + if (edge < PNG_RESCALE_FLOOR_PX) break; + if (!fitWithinEdge(image, edge)) continue; + const smallerPng = await image.getBuffer('image/png', { deflateLevel: 9 }); + if (smallerPng.length <= byteBudget) return consider(smallerPng, 'image/png'); + consider(smallerPng, 'image/png'); + } + + const atFloor = await jpegLadder(); + if (atFloor !== null) return atFloor; + for (const edge of fallbackEdges) { + if (edge >= PNG_RESCALE_FLOOR_PX) continue; + if (!fitWithinEdge(image, edge)) continue; + const atEdge = await jpegLadder(); + if (atEdge !== null) return atEdge; + } + return smallest!; + } + + const atFitted = await jpegLadder(); + if (atFitted !== null) return atFitted; + for (const edge of fallbackEdges) { + if (!fitWithinEdge(image, edge)) continue; + const atEdge = await jpegLadder(); + if (atEdge !== null) return atEdge; + } + + return smallest!; +} + +function fitWithinEdge(image: JimpImage, edge: number): boolean { + const longest = Math.max(image.width, image.height); + if (longest <= edge) return false; + const factor = edge / longest; + image.resize({ + w: Math.max(1, Math.round(image.width * factor)), + h: Math.max(1, Math.round(image.height * factor)), + }); + return true; +} + +type CropErrorKind = + | 'empty' + | 'unsupported_format' + | 'region_invalid' + | 'too_large' + | 'out_of_bounds' + | 'budget' + | 'decode_failed'; + +interface CompressEventResult { + readonly mimeType: string; + readonly width: number; + readonly height: number; + readonly originalWidth: number; + readonly originalHeight: number; + readonly originalByteLength: number; + readonly finalByteLength: number; +} + +function reportCompressEvent( + telemetry: ITelemetryService | undefined, + source: string | undefined, + input: { + readonly outcome: CompressOutcome; + readonly startedAt: number; + readonly inputMime: string; + readonly exifTransposed: boolean; + readonly result: CompressEventResult; + }, +): void { + if (telemetry === undefined || source === undefined) return; + try { + const event: ImageCompressEvent = { + source, + outcome: input.outcome, + input_mime: input.inputMime, + output_mime: normalizeImageMime(input.result.mimeType), + original_bytes: input.result.originalByteLength, + final_bytes: input.result.finalByteLength, + original_width: input.result.originalWidth, + original_height: input.result.originalHeight, + final_width: input.result.width, + final_height: input.result.height, + exif_transposed: input.exifTransposed, + duration_ms: Date.now() - input.startedAt, + }; + telemetry.track2('image_compress', event); + } catch { + } +} + +function reportCropEvent( + telemetry: ITelemetryService | undefined, + source: string | undefined, + input: { + readonly startedAt: number; + readonly ok: boolean; + readonly errorKind?: CropErrorKind; + readonly result?: CropImageSuccess; + }, +): void { + if (telemetry === undefined || source === undefined) return; + try { + const { result } = input; + const originalPixels = + result === undefined ? 0 : result.originalWidth * result.originalHeight; + const event: ImageCropEvent = { + source, + ok: input.ok, + error_kind: input.errorKind, + resized: result?.resized, + original_width: result?.originalWidth, + original_height: result?.originalHeight, + region_area_ratio: + result === undefined || originalPixels === 0 + ? undefined + : (result.region.width * result.region.height) / originalPixels, + final_bytes: result?.finalByteLength, + duration_ms: Date.now() - input.startedAt, + }; + telemetry.track2('image_crop', event); + } catch { + } +} diff --git a/packages/agent-core-v2/src/agent/media/image-format-policy.ts b/packages/agent-core-v2/src/agent/media/image-format-policy.ts new file mode 100644 index 0000000000000000000000000000000000000000..df914fd31e114553ed0f712a2179b08ea203da1e --- /dev/null +++ b/packages/agent-core-v2/src/agent/media/image-format-policy.ts @@ -0,0 +1,176 @@ +import { providerImagePolicy } from '#human/llm/media/image-formats'; + +import { IMAGE_MIME_BY_SUFFIX, sniffMediaFromMagic } from './file-type'; + +const IMAGE_FORMAT_LABELS: Readonly> = Object.freeze({ + 'image/png': 'PNG', + 'image/jpeg': 'JPEG', + 'image/gif': 'GIF', + 'image/webp': 'WebP', + 'image/bmp': 'BMP', + 'image/heic': 'HEIC', + 'image/heif': 'HEIF', +}); + +function acceptedFormatsText(providerType: string | undefined): string { + const labels = [...providerImagePolicy(providerType).acceptedMimes].map( + (mime) => IMAGE_FORMAT_LABELS[mime] ?? mime, + ); + return `${labels.slice(0, -1).join(', ')}, and ${labels.at(-1)}`; +} + +interface UnsupportedImageFormatInfo { + readonly linuxDecoder?: { readonly command: string; readonly packageName: string }; +} + +const UNSUPPORTED_IMAGE_FORMATS: Readonly> = + Object.freeze({ + 'image/avif': {}, + 'image/heic': { linuxDecoder: { command: 'heif-convert', packageName: 'libheif-examples' } }, + 'image/heif': { linuxDecoder: { command: 'heif-convert', packageName: 'libheif-examples' } }, + 'image/bmp': {}, + 'image/tiff': {}, + 'image/x-icon': {}, + }); + +export function normalizeImageMime(mimeType: string): string { + const lower = mimeType.trim().toLowerCase(); + const semi = lower.indexOf(';'); + const base = (semi === -1 ? lower : lower.slice(0, semi)).trim(); + return base === 'image/jpg' ? 'image/jpeg' : base; +} + +const BASE64_SNIFF_CHARS = 48; + +export function decodeBase64Prefix(base64: string): Buffer { + return Buffer.from(base64.slice(0, BASE64_SNIFF_CHARS), 'base64'); +} + +export function resolveEffectiveImageMime(declaredMime: string, header: Uint8Array): string { + const sniffed = sniffMediaFromMagic(header); + return sniffed !== null ? sniffed.mimeType : declaredMime; +} + +export function unsupportedImageMimeFromUrl(url: string, providerType?: string): string | null { + let path = url; + const query = path.indexOf('?'); + if (query !== -1) path = path.slice(0, query); + const hash = path.indexOf('#'); + if (hash !== -1) path = path.slice(0, hash); + const dot = path.lastIndexOf('.'); + if (dot === -1) return null; + const ext = path.slice(dot).toLowerCase(); + const mime = ext === '.svg' ? 'image/svg+xml' : IMAGE_MIME_BY_SUFFIX[ext]; + if (mime === undefined || isModelAcceptedImageMime(mime, providerType)) return null; + return mime; +} + +export function parseImageDataUrl(url: string): { mimeType: string; base64: string } | null { + const match = /^data:([^;,]+)(?:;[^;,]+)*?;base64,(.*)$/si.exec(url); + if (match === null) return null; + return { mimeType: match[1]!, base64: match[2]! }; +} + +export function isDataUrl(url: string): boolean { + return url.toLowerCase().startsWith('data:'); +} + +export function isModelAcceptedImageMime(mimeType: string, providerType?: string): boolean { + return providerImagePolicy(providerType).acceptedMimes.has(normalizeImageMime(mimeType)); +} + +export function buildImageConversionGuidance( + path: string, + mimeType: string, + osKind: string, +): string { + const converted = path.replace(/\.[^./\\]+$/, '') + '.jpg'; + return ( + `"${path}" is an ${mimeType} image, which the provider does not accept. ` + + 'Convert it to JPEG first, then read the converted file. ' + + imageConversionCommand( + path, + converted, + osKind, + UNSUPPORTED_IMAGE_FORMATS[normalizeImageMime(mimeType)], + ) + ); +} + +export function buildOversizedImageConversionGuidance( + path: string, + mimeType: string, + osKind: string, + byteLength: number, + inlineByteBudget: number, +): string { + const converted = path.replace(/\.[^./\\]+$/, '') + '.jpg'; + return ( + `"${path}" is a ${String(byteLength)}-byte ${mimeType} image, over the ` + + `${String(inlineByteBudget)}-byte per-image limit, and this format cannot be ` + + 'downsampled locally. ' + + 'Convert it to JPEG first, then read the converted file. ' + + imageConversionCommand( + path, + converted, + osKind, + UNSUPPORTED_IMAGE_FORMATS[normalizeImageMime(mimeType)], + ) + ); +} + +function imageConversionCommand( + path: string, + converted: string, + osKind: string, + format: UnsupportedImageFormatInfo | undefined, +): string { + const magick = `magick "${path}" "${converted}"`; + const linuxDecoder = format?.linuxDecoder; + switch (osKind) { + case 'macOS': + return `On macOS: sips -s format jpeg "${path}" --out "${converted}"`; + case 'Linux': + return linuxDecoder === undefined + ? `On Linux, with ImageMagick: ${magick}` + : `On Linux: ${linuxDecoder.command} "${path}" "${converted}" ` + + `(package ${linuxDecoder.packageName}), or with ImageMagick: ${magick}`; + case 'Windows': + return ( + `On Windows, with ImageMagick: ${magick} ` + + '(install it first if missing: winget install ImageMagick.ImageMagick)' + ); + default: + return ( + `Options: sips -s format jpeg "${path}" --out "${converted}" (macOS)` + + (linuxDecoder === undefined + ? '' + : `, ${linuxDecoder.command} "${path}" "${converted}" ` + + `(Linux, package ${linuxDecoder.packageName})`) + + `, or ${magick} (ImageMagick)` + ); + } +} + +export function buildUnsupportedImageNotice( + mimeType: string, + name?: string, + providerType?: string, +): string { + const what = + name === undefined || name.length === 0 + ? `unsupported image format ${mimeType}` + : `"${name}" uses unsupported image format ${mimeType}`; + return ( + `[Image omitted: ${what}. The current model provider accepts only ` + + `${acceptedFormatsText(providerType)} — convert it to PNG or JPEG and try again.]` + ); +} + +export function buildMalformedImageNotice(url: string): string { + const shown = url.length > 80 ? `${url.slice(0, 80)}…` : url; + return ( + `[Image omitted: "${shown}" is not a valid data URL (its header or payload ` + + 'could not be parsed). Re-encode the image as PNG or JPEG and try again.]' + ); +} diff --git a/packages/agent-core-v2/src/agent/media/image-originals.ts b/packages/agent-core-v2/src/agent/media/image-originals.ts new file mode 100644 index 0000000000000000000000000000000000000000..534e47905433dff9eb8e66b9224a97cfb7d27354 --- /dev/null +++ b/packages/agent-core-v2/src/agent/media/image-originals.ts @@ -0,0 +1,75 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readdir, stat, unlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const DEFAULT_MAX_TOTAL_BYTES = 1024 * 1024 * 1024; + +const MIME_EXTENSION: Readonly> = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/jpg': 'jpg', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/bmp': 'bmp', + 'image/tiff': 'tif', +}; + +export interface PersistOriginalImageOptions { + readonly dir?: string; + readonly maxTotalBytes?: number; +} + +export function originalImageCacheDir(): string { + return join(tmpdir(), 'kimi-code-original-images'); +} + +export function sessionMediaOriginalsDir(sessionDir: string): string { + return join(sessionDir, 'media-originals'); +} + +export async function persistOriginalImage( + bytes: Uint8Array, + mimeType: string, + options: PersistOriginalImageOptions = {}, +): Promise { + if (bytes.length === 0) return null; + const dir = options.dir ?? originalImageCacheDir(); + const maxTotalBytes = options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES; + try { + const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 32); + const extension = MIME_EXTENSION[mimeType.trim().toLowerCase()] ?? 'img'; + const path = join(dir, `${hash}.${extension}`); + await mkdir(dir, { recursive: true }); + + const existing = await stat(path).catch(() => null); + if (existing === null || existing.size !== bytes.length) { + await writeFile(path, bytes); + } + + await sweepCache(dir, maxTotalBytes); + const persisted = await stat(path).catch(() => null); + return persisted === null ? null : path; + } catch { + return null; + } +} + +async function sweepCache(dir: string, maxTotalBytes: number): Promise { + const names = await readdir(dir); + const entries: { path: string; size: number; mtimeMs: number }[] = []; + for (const name of names) { + const path = join(dir, name); + const info = await stat(path).catch(() => null); + if (info === null || !info.isFile()) continue; + entries.push({ path, size: info.size, mtimeMs: info.mtimeMs }); + } + let total = entries.reduce((sum, entry) => sum + entry.size, 0); + if (total <= maxTotalBytes) return; + entries.sort((a, b) => a.mtimeMs - b.mtimeMs); + for (const entry of entries) { + if (total <= maxTotalBytes) break; + await unlink(entry.path).catch(() => undefined); + total -= entry.size; + } +} diff --git a/packages/agent-core-v2/src/agent/media/imageConfigBridge.ts b/packages/agent-core-v2/src/agent/media/imageConfigBridge.ts new file mode 100644 index 0000000000000000000000000000000000000000..a8ee4a3fb4d661391135bd5b2660d96b47b2ba92 --- /dev/null +++ b/packages/agent-core-v2/src/agent/media/imageConfigBridge.ts @@ -0,0 +1,47 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IConfigService } from '#/app/config/config'; +import { + setConfiguredMaxImageEdgePx, + setConfiguredReadImageByteBudget, +} from '#/agent/media/image-compress'; + +import { IMAGE_SECTION, type ImageConfig } from './configSection'; + +export interface IImageConfigBridge { + readonly _serviceBrand: undefined; +} + +export const IImageConfigBridge: ServiceIdentifier = + createDecorator('imageConfigBridge'); + +export class ImageConfigBridge extends Disposable implements IImageConfigBridge { + declare readonly _serviceBrand: undefined; + + constructor(@IConfigService private readonly config: IConfigService) { + super(); + this.push(this.config.get(IMAGE_SECTION)); + this._register( + this.config.onDidSectionChange((e) => { + if (e.domain === IMAGE_SECTION) { + this.push(e.value as ImageConfig); + } + }), + ); + } + + private push(image: ImageConfig | undefined): void { + setConfiguredMaxImageEdgePx(image?.maxEdgePx); + setConfiguredReadImageByteBudget(image?.readByteBudget); + } +} + +registerScopedService( + LifecycleScope.Agent, + IImageConfigBridge, + ImageConfigBridge, + ScopeActivation.OnScopeCreated, + 'media', +); diff --git a/packages/agent-core-v2/src/agent/media/kimiFileUrl.ts b/packages/agent-core-v2/src/agent/media/kimiFileUrl.ts new file mode 100644 index 0000000000000000000000000000000000000000..0edb6ef5fc85262f7b0450c1d7f418ceb26b4e0e --- /dev/null +++ b/packages/agent-core-v2/src/agent/media/kimiFileUrl.ts @@ -0,0 +1,6 @@ +export { + buildDaemonFileUrl as buildKimiFileUrl, + isDaemonFileUrl as isKimiFileUrl, + parseDaemonFileUrl as parseKimiFileUrl, + type DaemonFileRef as KimiFileRef, +} from './mediaRef'; diff --git a/packages/agent-core-v2/src/agent/media/mediaRef.ts b/packages/agent-core-v2/src/agent/media/mediaRef.ts new file mode 100644 index 0000000000000000000000000000000000000000..d1ea3e2fd02d62da65a7eac880dc1f2e2c46bc87 --- /dev/null +++ b/packages/agent-core-v2/src/agent/media/mediaRef.ts @@ -0,0 +1,193 @@ +import { join } from 'node:path'; + +import type { ContentPart } from '#human/llm/message'; + +export type MediaKind = 'image' | 'video' | 'audio' | 'file'; + +export const IMAGE_MIME_BY_SUFFIX: Readonly> = Object.freeze({ + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.bmp': 'image/bmp', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.webp': 'image/webp', + '.ico': 'image/x-icon', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.avif': 'image/avif', + '.svgz': 'image/svg+xml', +}); + +export const VIDEO_MIME_BY_SUFFIX: Readonly> = Object.freeze({ + '.mp4': 'video/mp4', + '.mpg': 'video/mpeg', + '.mpeg': 'video/mpeg', + '.mkv': 'video/x-matroska', + '.avi': 'video/x-msvideo', + '.mov': 'video/quicktime', + '.ogv': 'video/ogg', + '.wmv': 'video/x-ms-wmv', + '.webm': 'video/webm', + '.m4v': 'video/x-m4v', + '.flv': 'video/x-flv', + '.3gp': 'video/3gpp', + '.3g2': 'video/3gpp2', +}); + +export const AUDIO_MIME_BY_SUFFIX: Readonly> = Object.freeze({ + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.m4a': 'audio/mp4', + '.ogg': 'audio/ogg', + '.oga': 'audio/ogg', + '.flac': 'audio/flac', + '.aac': 'audio/aac', + '.opus': 'audio/opus', + '.weba': 'audio/webm', + '.wma': 'audio/x-ms-wma', +}); + +const IMAGE_EXT_BY_MIME = invertMimeBySuffix(IMAGE_MIME_BY_SUFFIX); +const VIDEO_EXT_BY_MIME = invertMimeBySuffix(VIDEO_MIME_BY_SUFFIX); +const AUDIO_EXT_BY_MIME = invertMimeBySuffix(AUDIO_MIME_BY_SUFFIX); + +function invertMimeBySuffix(table: Readonly>): Readonly> { + const out: Record = {}; + for (const [suffix, mime] of Object.entries(table)) { + out[mime] ??= suffix; + } + return Object.freeze(out); +} + +export function mediaExtensionForMime(mimeType: string): string | undefined { + const semi = mimeType.indexOf(';'); + const base = (semi === -1 ? mimeType : mimeType.slice(0, semi)).trim().toLowerCase(); + return VIDEO_EXT_BY_MIME[base] ?? IMAGE_EXT_BY_MIME[base] ?? AUDIO_EXT_BY_MIME[base]; +} + +function mediaSuffix(path: string): string { + const idx = path.lastIndexOf('.'); + if (idx === -1) return ''; + const lastSep = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + if (idx <= lastSep + 1) return ''; + return path.slice(idx).toLowerCase(); +} + +export function mediaKindForPath(path: string): 'image' | 'video' | 'audio' | undefined { + const suffix = mediaSuffix(path); + if (suffix in IMAGE_MIME_BY_SUFFIX) return 'image'; + if (suffix in VIDEO_MIME_BY_SUFFIX) return 'video'; + if (suffix in AUDIO_MIME_BY_SUFFIX) return 'audio'; + return undefined; +} + +export function mediaKindForMime(mimeType: string): 'image' | 'video' | 'audio' | undefined { + const semi = mimeType.indexOf(';'); + const base = (semi === -1 ? mimeType : mimeType.slice(0, semi)).trim().toLowerCase(); + if (base.startsWith('image/')) return 'image'; + if (base.startsWith('video/')) return 'video'; + if (base.startsWith('audio/')) return 'audio'; + return undefined; +} + +export function mediaKindOfPart(part: ContentPart): 'image' | 'video' | 'audio' | undefined { + if (part.type === 'image_url') return 'image'; + if (part.type === 'video_url') return 'video'; + if (part.type === 'audio_url') return 'audio'; + return undefined; +} + +const KIMI_FILE_SCHEME = 'kimi-file://'; + +export interface DaemonFileRef { + readonly fileId: string; +} + +export function isDaemonFileUrl(url: string): boolean { + return url.startsWith(KIMI_FILE_SCHEME); +} + +export function buildDaemonFileUrl(fileId: string): string { + return `${KIMI_FILE_SCHEME}${fileId}`; +} + +export function parseDaemonFileUrl(url: string): DaemonFileRef | undefined { + if (!url.startsWith(KIMI_FILE_SCHEME)) return undefined; + const rest = url.slice(KIMI_FILE_SCHEME.length); + const queryAt = rest.indexOf('?'); + const fileId = queryAt === -1 ? rest : rest.slice(0, queryAt); + return fileId.length > 0 ? { fileId } : undefined; +} + +export function daemonFileRefFromPart( + part: ContentPart, +): { readonly kind: 'image' | 'video'; readonly ref: DaemonFileRef } | undefined { + if (part.type === 'image_url') { + const ref = parseDaemonFileUrl(part.imageUrl.url); + return ref === undefined ? undefined : { kind: 'image', ref }; + } + if (part.type === 'video_url') { + const ref = parseDaemonFileUrl(part.videoUrl.url); + return ref === undefined ? undefined : { kind: 'video', ref }; + } + return undefined; +} + +export const SESSION_MEDIA_DIR = 'media'; + +export function sessionMediaFilePath(sessionDir: string, fileId: string, ext: string): string { + return join(sessionDir, SESSION_MEDIA_DIR, `${fileId}${ext}`); +} + +const MEDIA_PATH_TAG_RE = /<(image|video|audio|file)\b[^>]*?\bpath="([^"]*)"[^>]*>(?:<\/\1>)?/g; + +export interface MediaPathTag { + readonly kind: MediaKind; + readonly path: string; + readonly index: number; + readonly text: string; +} + +export function escapeMediaAttribute(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>'); +} + +export function unescapeMediaAttribute(value: string): string { + return value + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&'); +} + +export function buildMediaPathTag(kind: MediaKind, path: string): string { + return `<${kind} path="${escapeMediaAttribute(path)}">`; +} + +export function matchMediaPathTags(text: string): MediaPathTag[] { + const tags: MediaPathTag[] = []; + for (const match of text.matchAll(MEDIA_PATH_TAG_RE)) { + tags.push({ + kind: match[1] as MediaKind, + path: unescapeMediaAttribute(match[2]!), + index: match.index, + text: match[0], + }); + } + return tags; +} + +export function matchSingleMediaPathTag(text: string): MediaPathTag | undefined { + const trimmed = text.trim(); + if (trimmed.length === 0) return undefined; + const tags = matchMediaPathTags(trimmed); + if (tags.length !== 1) return undefined; + const tag = tags[0]!; + return tag.index === 0 && tag.text.length === trimmed.length ? tag : undefined; +} diff --git a/packages/agent-core-v2/src/agent/media/mediaResolver.ts b/packages/agent-core-v2/src/agent/media/mediaResolver.ts new file mode 100644 index 0000000000000000000000000000000000000000..a78a8c22333e3088a117b25701a7118c9ea02731 --- /dev/null +++ b/packages/agent-core-v2/src/agent/media/mediaResolver.ts @@ -0,0 +1,18 @@ +import { createDecorator } from '#/_base/di/instantiation'; +import type { Message } from '#/llm-adapter/contract/message'; +import type { ModelRequester } from '#/llm-adapter/model/model-requester'; + +export interface IAgentMediaResolverService { + readonly _serviceBrand: undefined; + + resolve( + messages: readonly Message[], + requester: ModelRequester, + signal?: AbortSignal, + ): Promise; + displayPaths(messages: readonly Message[]): Promise>; +} + +export const IAgentMediaResolverService = createDecorator( + 'agentVideoResolverService', +); diff --git a/packages/agent-core-v2/src/agent/media/mediaResolverService.ts b/packages/agent-core-v2/src/agent/media/mediaResolverService.ts new file mode 100644 index 0000000000000000000000000000000000000000..8d1a5e8873fecd4c78522f1a1a996097c2622527 --- /dev/null +++ b/packages/agent-core-v2/src/agent/media/mediaResolverService.ts @@ -0,0 +1,652 @@ +import { createHash } from 'node:crypto'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { defineState } from '#/state/state'; +import { IEventDispatcher } from '#/state/eventDispatcher'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { WarningIssued } from '#/agent/profile/profileOps'; +import { IFileService } from '#/app/file/fileService'; +import { LifecycleScope } from '#/app/scopes'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import type { Message } from '#/llm-adapter/contract/message'; +import { ImageUploadUnsupportedError } from '#/llm-adapter/contract/errors'; +import type { ContentPart } from '#human/llm/message'; +import type { Model } from '#/llm-adapter/model/catalog'; +import type { ModelRequester } from '#/llm-adapter/model/model-requester'; +import { runWithCredentialRecovery } from '#/llm-adapter/model/credential-recovery'; +import { IBlobStore } from '#/persistence/interface/blobStore'; + +import { detectFileType, MEDIA_SNIFF_BYTES } from './file-type'; +import { isDataUrl, isModelAcceptedImageMime, normalizeImageMime } from './image-format-policy'; +import { + buildMediaPathTag, + type DaemonFileRef, + daemonFileRefFromPart, + matchSingleMediaPathTag, + parseDaemonFileUrl, +} from './mediaRef'; +import { ISessionMediaStore } from './sessionMediaStore'; +import { IAgentMediaResolverService } from './mediaResolver'; +import { createVideoUploader } from './registerMediaTools'; +import { + inlineVideoPart, + inlineVideoSupportedForProtocol, + isMediaUploadAuthError, + isVideoUploadUnsupportedError, +} from './videoUpload'; + +const VIDEO_CACHE_SCOPE = 'video-upload-cache'; +const IMAGE_CACHE_SCOPE = 'image-upload-cache'; +const PROVIDER_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const VIDEO_UNAVAILABLE_TEXT = + '[video omitted: the uploaded file is no longer available]'; +const IMAGE_UNAVAILABLE_TEXT = + '[image omitted: the uploaded file is no longer available]'; +const IMAGE_MEMO_MAX_BYTES = 8 * 1024 * 1024; +const IMAGE_MEMO_MAX_TOTAL_BYTES = 64 * 1024 * 1024; +const REQUEST_MEDIA_BUDGET_BYTES = 20 * 1024 * 1024; +const REQUEST_MEDIA_BUDGET_LOW_BYTES = 10 * 1024 * 1024; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +export const mediaResolvedKey = defineState>( + 'media.resolved', + () => new Map(), +); + +export const mediaBudgetDroppedKey = defineState>( + 'media.budgetDropped', + () => new Set(), +); + +interface MediaBudgetEntry { + readonly messageIndex: number; + readonly partIndex: number; + readonly key: string; + readonly fileId?: string; + readonly kind: 'image' | 'video'; + readonly bytes: number; +} + +export class AgentMediaResolverService implements IAgentMediaResolverService { + declare readonly _serviceBrand: undefined; + + constructor( + @IFileService private readonly files: IFileService, + @IBlobStore private readonly blobs: IBlobStore, + @ITelemetryService private readonly telemetry: ITelemetryService, + @IAgentStateService private readonly states: IAgentStateService, + @ISessionMediaStore private readonly mediaStore: ISessionMediaStore, + @IEventDispatcher private readonly dispatcher: IEventDispatcher, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + ) { + this.states.contributeState(mediaResolvedKey); + this.states.contributeState(mediaBudgetDroppedKey); + } + + private get resolved(): Map { + return this.states.get(mediaResolvedKey); + } + + private get budgetDropped(): Set { + return this.states.get(mediaBudgetDroppedKey); + } + + private readonly imageMemo = new Map< + string, + { part: ContentPart; bytes: number; mimeType: string } + >(); + private imageMemoBytes = 0; + private readonly imageUploadUnsupported = new Set(); + + async resolve( + messages: readonly Message[], + requester: ModelRequester, + signal?: AbortSignal, + ): Promise { + let changed = false; + const out: Message[] = []; + const budgetEntries: MediaBudgetEntry[] = []; + for (const message of messages) { + const content: ContentPart[] = []; + let messageChanged = false; + let sawVideoRef = false; + for (const part of message.content) { + const daemonPart = daemonFileRefFromPart(part); + if (daemonPart === undefined) { + const entry = inlineMediaBudgetEntry(part, out.length, content.length); + if (entry !== undefined) budgetEntries.push(entry); + content.push(part); + continue; + } + messageChanged = true; + sawVideoRef ||= daemonPart.kind === 'video'; + const resolved = + daemonPart.kind === 'video' + ? await this.resolveVideoPart(daemonPart.ref, requester, signal) + : await this.resolveImagePart(daemonPart.ref, requester, signal); + budgetEntries.push({ + messageIndex: out.length, + partIndex: content.length, + key: daemonPart.ref.fileId, + fileId: daemonPart.ref.fileId, + kind: daemonPart.kind, + bytes: inlinePartBytes(resolved), + }); + content.push(resolved); + } + out.push( + messageChanged + ? { + ...message, + content: + content.length > 0 + ? content + : [unavailableMediaText(sawVideoRef ? 'video' : 'image')], + } + : message, + ); + changed ||= messageChanged; + } + changed = (await this.applyMediaBudget(out, budgetEntries)) || changed; + return changed ? out : messages; + } + + async displayPaths(messages: readonly Message[]): Promise> { + const paths = new Map(); + for (const message of messages) { + for (const part of message.content) { + if (part.type !== 'image_url' && part.type !== 'video_url') continue; + const url = part.type === 'image_url' ? part.imageUrl.url : part.videoUrl.url; + const ref = parseDaemonFileUrl(url); + if (ref === undefined || paths.has(url)) continue; + const path = await this.displayPath(ref); + if (path !== undefined) paths.set(url, path); + } + } + return paths; + } + + private async applyMediaBudget( + out: Message[], + entries: readonly MediaBudgetEntry[], + ): Promise { + if (entries.length === 0) return false; + let changed = false; + const dropped = this.budgetDropped; + const pending = new Map(); + for (const entry of entries) { + if (dropped.has(entry.key)) { + await this.replaceWithMediaTag(out, entry); + changed = true; + continue; + } + pending.set(entry.key, (pending.get(entry.key) ?? 0) + entry.bytes); + } + let total = 0; + for (const bytes of pending.values()) total += bytes; + if (total <= REQUEST_MEDIA_BUDGET_BYTES) return changed; + + const droppedNow = new Set(); + for (const [key, bytes] of pending) { + if (total <= REQUEST_MEDIA_BUDGET_LOW_BYTES) break; + if (bytes === 0) continue; + dropped.add(key); + droppedNow.add(key); + total -= bytes; + } + for (const entry of entries) { + if (droppedNow.has(entry.key)) await this.replaceWithMediaTag(out, entry); + } + const hasUntrackedInlineMedia = entries.some( + (entry) => droppedNow.has(entry.key) && entry.fileId === undefined, + ); + try { + void this.dispatcher.dispatch( + new WarningIssued({ + agentId: this.scopeContext.agentId, + code: 'media-budget-exceeded', + message: + `Conversation media exceeded the ${String(REQUEST_MEDIA_BUDGET_BYTES / (1024 * 1024))} MB ` + + `per-request budget; ${String(droppedNow.size)} older media item(s) were omitted` + + (hasUntrackedInlineMedia ? '.' : ' and remain available at their saved paths.'), + }), + ); + } catch { + } + return true; + } + + private async replaceWithMediaTag(out: Message[], entry: MediaBudgetEntry): Promise { + const message = out[entry.messageIndex]!; + const content = [...message.content]; + if (entry.fileId === undefined) { + content[entry.partIndex] = budgetOmittedMedia(entry.kind); + } else { + const path = await this.displayPath({ fileId: entry.fileId }); + content[entry.partIndex] = entry.kind === 'video' ? videoTag(path) : degradedImage(path); + } + out[entry.messageIndex] = { ...message, content }; + } + + private displayPath(ref: DaemonFileRef): Promise { + return this.mediaStore.resolveDisplayPath(ref.fileId); + } + + private async resolveImagePart( + ref: DaemonFileRef, + requester: ModelRequester, + signal: AbortSignal | undefined, + ): Promise { + const model = requester.model; + if (!model.capabilities.image_in) { + this.telemetry.track2('media_resolve_fallback', { + kind: 'image', + reason: 'unsupported', + model: model.name, + }); + return degradedImage(await this.displayPath(ref)); + } + const providerKey = model.providerType ?? model.protocol; + const uploader = this.imageUploadUnsupported.has(providerKey) + ? undefined + : requester.uploadImage?.bind(requester); + const inlineKey = `image\0${ref.fileId}`; + if (uploader === undefined) { + const memoed = this.memoedImage(inlineKey, model.providerType); + if (memoed !== undefined) return memoed; + return this.resolveImageUncached(ref, requester, inlineKey, undefined, signal); + } + const cacheKey = `image\0${ref.fileId}\0${providerKey}\0${model.protocol}\0${model.baseUrl ?? ''}\0${await accountHashFor(model)}`; + const memoed = this.resolved.get(cacheKey); + if (memoed !== undefined) return memoed; + const cachedLlmFileId = await this.readCachedUpload(IMAGE_CACHE_SCOPE, cacheKey); + if (cachedLlmFileId !== undefined) { + const part: ContentPart = { + type: 'image_url', + imageUrl: { url: `ms://${cachedLlmFileId}`, id: cachedLlmFileId }, + }; + this.resolved.set(cacheKey, part); + return part; + } + return this.resolveImageUncached(ref, requester, inlineKey, { uploader, cacheKey }, signal); + } + + private async resolveImageUncached( + ref: DaemonFileRef, + requester: ModelRequester, + inlineKey: string, + upload: { readonly uploader: ImageUploader; readonly cacheKey: string } | undefined, + signal: AbortSignal | undefined, + ): Promise { + const model = requester.model; + const path = await this.displayPath(ref); + + let source: { readonly bytes: Buffer; readonly filename: string }; + try { + source = await this.readMedia(ref, signal); + } catch { + signal?.throwIfAborted(); + this.telemetry.track2('media_resolve_fallback', { + kind: 'image', + reason: 'read_failed', + model: model.name, + }); + return degradedImage(path); + } + + const fileType = detectFileType( + source.filename, + source.bytes.subarray(0, MEDIA_SNIFF_BYTES), + 'media', + ); + const mimeType = normalizeImageMime(fileType.mimeType); + if (fileType.kind !== 'image' || !isModelAcceptedImageMime(mimeType, model.providerType)) { + this.telemetry.track2('media_resolve_fallback', { + kind: 'image', + reason: 'invalid', + model: model.name, + }); + return degradedImage(path); + } + + if (upload !== undefined) { + const uploaded = await this.uploadImagePart(requester, source, mimeType, upload, signal); + if (uploaded !== undefined) return uploaded; + } + + const part: ContentPart = { + type: 'image_url', + imageUrl: { url: `data:${mimeType};base64,${source.bytes.toString('base64')}` }, + }; + if (source.bytes.length <= IMAGE_MEMO_MAX_BYTES) { + this.memoizeImage(inlineKey, part, source.bytes.length, mimeType); + } + return part; + } + + private async uploadImagePart( + requester: ModelRequester, + source: { readonly bytes: Buffer; readonly filename: string }, + mimeType: string, + upload: { readonly uploader: ImageUploader; readonly cacheKey: string }, + signal: AbortSignal | undefined, + ): Promise { + const model = requester.model; + try { + const uploaded = await runWithCredentialRecovery( + model.credentialProvider, + () => + upload.uploader( + { data: source.bytes, mimeType, filename: source.filename }, + { signal }, + ), + signal, + ); + const llmFileId = uploaded.imageUrl.id ?? msFileIdFromUrl(uploaded.imageUrl.url); + if (llmFileId !== undefined) { + await this.writeCachedUpload(IMAGE_CACHE_SCOPE, upload.cacheKey, llmFileId); + } + this.resolved.set(upload.cacheKey, uploaded); + return uploaded; + } catch (error) { + if (signal?.aborted) throw error; + if (isMediaUploadAuthError(error)) throw error; + if (error instanceof ImageUploadUnsupportedError) { + this.imageUploadUnsupported.add(model.providerType ?? model.protocol); + } + this.telemetry.track2('media_resolve_fallback', { + kind: 'image', + reason: 'upload_failed', + model: model.name, + }); + return undefined; + } + } + + private memoedImage(cacheKey: string, providerType: string | undefined): ContentPart | undefined { + const entry = this.imageMemo.get(cacheKey); + if (entry === undefined) return undefined; + if (!isModelAcceptedImageMime(entry.mimeType, providerType)) return undefined; + this.imageMemo.delete(cacheKey); + this.imageMemo.set(cacheKey, entry); + return entry.part; + } + + private memoizeImage( + cacheKey: string, + part: ContentPart, + bytes: number, + mimeType: string, + ): void { + const previous = this.imageMemo.get(cacheKey); + if (previous !== undefined) { + this.imageMemo.delete(cacheKey); + this.imageMemoBytes -= previous.bytes; + } + this.imageMemo.set(cacheKey, { part, bytes, mimeType }); + this.imageMemoBytes += bytes; + for (const [key, entry] of this.imageMemo) { + if (this.imageMemoBytes <= IMAGE_MEMO_MAX_TOTAL_BYTES) return; + this.imageMemo.delete(key); + this.imageMemoBytes -= entry.bytes; + } + } + + private async resolveVideoPart( + ref: DaemonFileRef, + requester: ModelRequester, + signal: AbortSignal | undefined, + ): Promise { + const model = requester.model; + if (!model.capabilities.video_in) return videoTag(await this.displayPath(ref)); + const providerKey = model.providerType ?? model.protocol; + const cacheKey = + requester.uploadVideo === undefined + ? `${ref.fileId}\0${providerKey}` + : `${ref.fileId}\0${providerKey}\0${model.protocol}\0${model.baseUrl ?? ''}\0${await accountHashFor(model)}`; + + const memoed = this.resolved.get(cacheKey); + if (memoed !== undefined) return this.memoedOutcome(ref, memoed); + + const { part, memoize } = await this.resolveVideoUncached(ref, requester, cacheKey, signal); + if (memoize) this.resolved.set(cacheKey, part); + return part; + } + + private async memoedOutcome(ref: DaemonFileRef, memoed: ContentPart): Promise { + if (memoed.type !== 'text') return memoed; + const tag = matchSingleMediaPathTag(memoed.text); + if (tag === undefined) return memoed; + const path = await this.displayPath(ref); + if (path === undefined || path === tag.path) return memoed; + return { type: 'text', text: buildMediaPathTag(tag.kind, path) }; + } + + private async resolveVideoUncached( + ref: DaemonFileRef, + requester: ModelRequester, + cacheKey: string, + signal: AbortSignal | undefined, + ): Promise<{ part: ContentPart; memoize: boolean }> { + const cachedLlmFileId = await this.readCachedUpload(VIDEO_CACHE_SCOPE, cacheKey); + if (cachedLlmFileId !== undefined) { + return { + part: { type: 'video_url', videoUrl: { url: `ms://${cachedLlmFileId}`, id: cachedLlmFileId } }, + memoize: true, + }; + } + const tagPath = await this.displayPath(ref); + + let source: { readonly bytes: Buffer; readonly filename: string }; + try { + source = await this.readMedia(ref, signal); + } catch { + signal?.throwIfAborted(); + return { part: videoTag(tagPath), memoize: true }; + } + + const { bytes, filename } = source; + const fileType = detectFileType(filename, bytes.subarray(0, MEDIA_SNIFF_BYTES), 'media'); + if (fileType.kind !== 'video') return { part: videoTag(tagPath), memoize: true }; + const mimeType = fileType.mimeType; + + const model = requester.model; + const inlineSupported = inlineVideoSupportedForProtocol(model.protocol); + + const uploader = createVideoUploader(requester, { + client: this.telemetry, + props: { + model: model.name, + provider_type: model.providerType ?? model.protocol, + protocol: model.protocol, + }, + }); + if (uploader === undefined) { + return { + part: inlineSupported ? inlineVideoPart(bytes, mimeType) : videoTag(tagPath), + memoize: true, + }; + } + + try { + const uploaded = await runWithCredentialRecovery( + requester.model.credentialProvider, + () => uploader({ data: bytes, mimeType, filename }, { signal }), + signal, + ); + const llmFileId = uploaded.videoUrl.id ?? msFileIdFromUrl(uploaded.videoUrl.url); + if (llmFileId !== undefined) await this.writeCachedUpload(VIDEO_CACHE_SCOPE, cacheKey, llmFileId); + return { part: uploaded, memoize: true }; + } catch (error) { + if (signal?.aborted) throw error; + if (isMediaUploadAuthError(error)) throw error; + this.telemetry.track2('media_resolve_fallback', { + kind: 'video', + reason: 'upload_failed', + model: model.name, + }); + if (isVideoUploadUnsupportedError(error)) { + return { + part: inlineSupported ? inlineVideoPart(bytes, mimeType) : videoTag(tagPath), + memoize: true, + }; + } + return { part: videoTag(tagPath), memoize: false }; + } + } + + private async readMedia( + ref: DaemonFileRef, + signal: AbortSignal | undefined, + ): Promise<{ readonly bytes: Buffer; readonly filename: string }> { + try { + signal?.throwIfAborted(); + const file = await this.files.get(ref.fileId); + const bytes = await readStream(file.stream(), signal); + return { bytes, filename: file.meta.name }; + } catch { + signal?.throwIfAborted(); + const canonical = await this.mediaStore.read(ref.fileId); + if (canonical === undefined) throw new Error(`media ${ref.fileId} is unavailable`); + return { bytes: Buffer.from(canonical.data), filename: canonical.name }; + } + } + + private async readCachedUpload(scope: string, cacheKey: string): Promise { + const data = await this.blobs.get(scope, blobKey(cacheKey)).catch(() => undefined); + if (data === undefined) return undefined; + const llmFileId = textDecoder.decode(data); + return PROVIDER_ID_RE.test(llmFileId) ? llmFileId : undefined; + } + + private async writeCachedUpload( + scope: string, + cacheKey: string, + llmFileId: string, + ): Promise { + if (!PROVIDER_ID_RE.test(llmFileId)) return; + await this.blobs.put(scope, blobKey(cacheKey), textEncoder.encode(llmFileId)).catch( + () => undefined, + ); + } +} + +type ImageUploader = NonNullable; + +async function accountHashFor(model: Model): Promise { + let identity: string | undefined; + const authorization = model.headers['Authorization']; + if (authorization !== undefined) { + identity = `authorization\0${authorization.trim()}`; + } else { + try { + const apiKey = (await model.credentialProvider?.resolve())?.apiKey; + if (apiKey !== undefined && apiKey.length > 0) { + identity = `api-key\0${stableJwtSubject(apiKey) ?? apiKey}`; + } + } catch { + identity = undefined; + } + } + if (identity === undefined) return 'no-key'; + return createHash('sha256').update(identity).digest('hex').slice(0, 16); +} + +function stableJwtSubject(token: string): string | undefined { + const parts = token.split('.'); + if (parts.length !== 3) return undefined; + try { + const payload: unknown = JSON.parse(Buffer.from(parts[1]!, 'base64url').toString('utf8')); + if (typeof payload !== 'object' || payload === null) return undefined; + const sub = (payload as { sub?: unknown }).sub; + if (typeof sub === 'string' && sub.length > 0) return sub; + const userId = (payload as { user_id?: unknown }).user_id; + if (typeof userId === 'string' && userId.length > 0) return userId; + return undefined; + } catch { + return undefined; + } +} + +function inlineMediaBudgetEntry( + part: ContentPart, + messageIndex: number, + partIndex: number, +): MediaBudgetEntry | undefined { + if (part.type !== 'image_url' && part.type !== 'video_url') return undefined; + const url = part.type === 'image_url' ? part.imageUrl.url : part.videoUrl.url; + if (!isDataUrl(url)) return undefined; + const kind = part.type === 'image_url' ? 'image' : 'video'; + const hash = createHash('sha256').update(kind).update('\0').update(url).digest('hex'); + return { messageIndex, partIndex, key: `inline\0${hash}`, kind, bytes: url.length }; +} + +function inlinePartBytes(part: ContentPart): number { + if (part.type === 'image_url') { + return isDataUrl(part.imageUrl.url) ? part.imageUrl.url.length : 0; + } + if (part.type === 'video_url') { + return isDataUrl(part.videoUrl.url) ? part.videoUrl.url.length : 0; + } + return 0; +} + +function budgetOmittedMedia(kind: 'image' | 'video'): ContentPart { + return { type: 'text', text: `[${kind} omitted: dropped to fit the request media budget]` }; +} + +function degradedImage(path: string | undefined): ContentPart { + if (path === undefined) return unavailableMediaText('image'); + return { type: 'text', text: buildMediaPathTag('image', path) }; +} + +function unavailableMediaText(kind: 'image' | 'video'): ContentPart { + return { type: 'text', text: kind === 'video' ? VIDEO_UNAVAILABLE_TEXT : IMAGE_UNAVAILABLE_TEXT }; +} + +function videoTag(path: string | undefined): ContentPart { + if (path === undefined) { + return { type: 'text', text: VIDEO_UNAVAILABLE_TEXT }; + } + return { type: 'text', text: buildMediaPathTag('video', path) }; +} + +function msFileIdFromUrl(url: string): string | undefined { + if (!url.startsWith('ms://')) return undefined; + const id = url.slice('ms://'.length); + return id.length > 0 ? id : undefined; +} + +function blobKey(cacheKey: string): string { + return createHash('sha256').update(cacheKey).digest('hex'); +} + +async function readStream(stream: NodeJS.ReadableStream, signal?: AbortSignal): Promise { + const onAbort = (): void => { + const reason = signal?.reason instanceof Error ? signal.reason : undefined; + (stream as NodeJS.ReadableStream & { destroy?(error?: Error): void }).destroy?.(reason); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + const chunks: Buffer[] = []; + try { + signal?.throwIfAborted(); + for await (const chunk of stream) { + signal?.throwIfAborted(); + chunks.push(Buffer.from(chunk as string | Uint8Array)); + } + return Buffer.concat(chunks); + } finally { + signal?.removeEventListener('abort', onAbort); + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentMediaResolverService, + AgentMediaResolverService, + ScopeActivation.OnScopeCreated, + 'media', +); diff --git a/packages/agent-core-v2/src/agent/media/mediaTools.ts b/packages/agent-core-v2/src/agent/media/mediaTools.ts new file mode 100644 index 0000000000000000000000000000000000000000..9d5e2e628cf2c541788463cac802db91af7a9599 --- /dev/null +++ b/packages/agent-core-v2/src/agent/media/mediaTools.ts @@ -0,0 +1,8 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IAgentMediaToolsRegistrar { + readonly _serviceBrand: undefined; +} + +export const IAgentMediaToolsRegistrar: ServiceIdentifier = + createDecorator('agentMediaToolsRegistrar'); diff --git a/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts b/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts new file mode 100644 index 0000000000000000000000000000000000000000..e0ea757ad30e76e8208dba46b76ae4dab12f3574 --- /dev/null +++ b/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts @@ -0,0 +1,167 @@ +import { toDisposable, type IDisposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { defineState } from '#/state/state'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { IEventBus } from '#/app/event/eventBus'; +import { AgentStatusUpdated } from '#/agent/usage/usageEvents'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { IModelCatalog, type Model } from '#/llm-adapter/model/catalog'; +import { type ModelRequester } from '#/llm-adapter/model/model-requester'; +import { runWithCredentialRecovery } from '#/llm-adapter/model/credential-recovery'; +import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; +import { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { IAgentProfileService } from '#/agent/profile/profile'; +import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; +import { extendWorkspaceWithSkillRoots } from '#/tool/path-access'; + +import { IAgentMediaToolsRegistrar } from './mediaTools'; +import { createVideoUploader, registerMediaTools } from './registerMediaTools'; +import { ISessionMediaStore } from './sessionMediaStore'; + +export const mediaRegisteredKeyKey = defineState( + 'media.registeredKey', + () => undefined as string | undefined, +); + +export class AgentMediaToolsRegistrar extends Service implements IAgentMediaToolsRegistrar { + declare readonly _serviceBrand: undefined; + + private registration: IDisposable | undefined; + + constructor( + @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, + @IAgentProfileService private readonly profile: IAgentProfileService, + @IModelCatalog private readonly modelCatalog: IModelCatalog, + @IEventBus eventBus: IEventBus, + @IAgentRuntimeService private readonly runtime: IAgentRuntimeService, + @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, + @ITelemetryService private readonly telemetry: ITelemetryService, + @IAgentStateService private readonly states: IAgentStateService, + @ISessionSkillCatalog private readonly skillCatalog?: ISessionSkillCatalog, + @ISessionMediaStore private readonly attachmentStore?: ISessionMediaStore, + ) { + super(); + this.states.contributeState(mediaRegisteredKeyKey); + this.refresh(); + this._register(eventBus.subscribe(AgentStatusUpdated, () => this.refresh())); + this._register(this.runtime.onDidChange(() => 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 tryResolveModel(alias: string): Model | undefined { + if (alias === '') return undefined; + try { + return this.modelCatalog.get(alias); + } catch { + return undefined; + } + } + + private refresh(): void { + const capabilities = this.profile.getModelCapabilities(); + const modelAlias = this.profile.getModel(); + const hasRuntimeFs = this.runtime.isAvailable(['fs']); + if (!hasRuntimeFs && this.attachmentStore === undefined) { + const key = [ + modelAlias, + String(capabilities.image_in), + String(capabilities.video_in), + 'runtime-unavailable', + ].join('|'); + if (key === this.registeredKey) return; + this.registeredKey = key; + this.registration?.dispose(); + this.registration = undefined; + return; + } + const inspected = hasRuntimeFs ? this.runtime.inspect() : undefined; + const identityKey = inspected === undefined ? 'session-attachments' : [ + inspected.identity.workspaceId, + inspected.identity.runtimeId, + inspected.identity.generation, + ].join('|'); + const model = this.tryResolveModel(modelAlias); + const key = [ + modelAlias, + model?.providerType ?? '', + model?.protocol ?? '', + String(capabilities.image_in), + String(capabilities.video_in), + identityKey, + inspected?.status, + inspected?.environment.pathClass, + String(hasRuntimeFs), + ].join('|'); + if (key === this.registeredKey) return; + this.registeredKey = key; + this.registration?.dispose(); + const workspaceCtx = this.workspaceCtx; + const skillCatalog = this.skillCatalog; + const runtime = this.runtime; + const pathClass = inspected?.environment.pathClass; + let requester: ModelRequester | undefined; + if (model !== undefined) { + try { + requester = this.modelCatalog.getRequester(modelAlias); + } catch { + requester = undefined; + } + } + const uploader = createVideoUploader(requester, { + client: this.telemetry, + props: { + model: modelAlias, + provider_type: model?.providerType ?? model?.protocol, + protocol: model?.protocol, + }, + }); + this.registration = registerMediaTools(this.toolRegistry, { + attachmentStore: this.attachmentStore, + runtime, + workspace: { + get workspaceDir() { + return workspaceCtx.workDir; + }, + get additionalDirs() { + return extendWorkspaceWithSkillRoots( + { workspaceDir: workspaceCtx.workDir, additionalDirs: workspaceCtx.additionalDirs }, + skillCatalog?.catalog.getSkillRoots() ?? [], + pathClass, + ).additionalDirs; + }, + }, + capabilities, + videoUploader: + uploader === undefined || requester === undefined + ? undefined + : (input, options) => + runWithCredentialRecovery( + requester.model.credentialProvider, + () => uploader(input, options), + options?.signal, + ), + inlineVideoSupported: model?.protocol !== 'openai' && model?.protocol !== 'openai_responses', + providerType: model?.providerType, + telemetry: this.telemetry, + }); + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentMediaToolsRegistrar, + AgentMediaToolsRegistrar, + ScopeActivation.OnScopeCreated, + 'media', +); diff --git a/packages/agent-core-v2/src/agent/media/promptMediaIntake.ts b/packages/agent-core-v2/src/agent/media/promptMediaIntake.ts new file mode 100644 index 0000000000000000000000000000000000000000..cfdda7247643a46dae06895a1b9674b6953ca575 --- /dev/null +++ b/packages/agent-core-v2/src/agent/media/promptMediaIntake.ts @@ -0,0 +1,46 @@ +import type { IFileService } from '#/app/file/fileService'; +import { abortable } from '#/_base/utils/abort'; +import type { ContentPart } from '#human/llm/message'; + +import { daemonFileRefFromPart } from './mediaRef'; +import { ISessionMediaStore } from './sessionMediaStore'; + +export interface PromptMediaIntakeDeps { + readonly files: IFileService; + readonly mediaStore: ISessionMediaStore; + readonly signal?: AbortSignal; +} + +export async function materializePromptDaemonRefs( + content: readonly ContentPart[], + deps: PromptMediaIntakeDeps, +): Promise { + for (const part of content) { + deps.signal?.throwIfAborted(); + const daemonPart = daemonFileRefFromPart(part); + if (daemonPart === undefined) continue; + await materializeRef(deps, daemonPart.ref.fileId).catch((_error: unknown) => { + deps.signal?.throwIfAborted(); + return undefined; + }); + } +} + +async function materializeRef(deps: PromptMediaIntakeDeps, fileId: string): Promise { + const file = + deps.signal === undefined + ? await deps.files.get(fileId) + : await abortable(deps.files.get(fileId), deps.signal); + try { + await deps.mediaStore.materialize({ + fileId, + size: file.meta.size, + name: file.meta.name, + mimeType: file.meta.media_type, + stream: () => file.stream(), + signal: deps.signal, + }); + } catch { + deps.signal?.throwIfAborted(); + } +} diff --git a/packages/agent-core-v2/src/agent/media/registerMediaTools.ts b/packages/agent-core-v2/src/agent/media/registerMediaTools.ts new file mode 100644 index 0000000000000000000000000000000000000000..b5f9d048b73a2485b68af91aeaa3649ac19d1a86 --- /dev/null +++ b/packages/agent-core-v2/src/agent/media/registerMediaTools.ts @@ -0,0 +1,90 @@ +import type { ModelCapability } from '#human/llm/capability'; +import type { ModelRequester } from '#/llm-adapter/model/model-requester'; +import type { VideoUploadEvent } from '#/app/telemetry/events'; +import type { ITelemetryService } from '#/app/telemetry/telemetry'; +import type { ISessionMediaStore } from './sessionMediaStore'; + +import { toDisposable, type IDisposable } from '#/_base/di/lifecycle'; +import type { WorkspaceConfig } from '#/tool/path-access'; +import type { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; +import type { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; +import { ReadMediaFileTool } from '#/agent/tools/read-media-file/readMediaFileTool'; +import type { VideoUploader } from '#/agent/tools/read-media-file/read-media-file'; + +export interface RegisterMediaToolsDeps { + readonly attachmentStore?: ISessionMediaStore; + readonly runtime: IAgentRuntimeService; + readonly workspace: WorkspaceConfig; + readonly capabilities: ModelCapability; + readonly videoUploader?: VideoUploader; + readonly telemetry?: ITelemetryService; + readonly inlineVideoSupported?: boolean; + readonly providerType?: string; +} + +export function registerMediaTools( + toolRegistry: IAgentToolRegistryService, + deps: RegisterMediaToolsDeps, +): IDisposable { + if ( + (!deps.runtime.isAvailable(['fs']) && deps.attachmentStore === undefined) || + (!deps.capabilities.image_in && !deps.capabilities.video_in) + ) { + return toDisposable(() => {}); + } + return toolRegistry.register( + new ReadMediaFileTool( + deps.runtime, + deps.workspace, + deps.capabilities, + deps.videoUploader, + deps.telemetry, + deps.inlineVideoSupported, + deps.providerType, + deps.attachmentStore, + ), + ); +} + +export function createVideoUploader( + requester: Pick | undefined, + telemetry?: VideoUploadTelemetry, +): VideoUploader | undefined { + const uploadVideo = requester?.uploadVideo; + if (uploadVideo === undefined) return undefined; + const bound = uploadVideo.bind(requester); + if (telemetry === undefined) return (input, options) => bound(input, options); + + return async (input, options) => { + const startedAt = Date.now(); + const base = { + ...telemetry.props, + mime_type: input.mimeType, + size_bytes: input.data.length, + }; + const track = (props: VideoUploadEvent): void => { + try { + telemetry.client.track2('video_upload', props); + } catch { + } + }; + try { + const part = await bound(input, options); + track({ ...base, outcome: 'success', duration_ms: Date.now() - startedAt }); + return part; + } catch (error) { + track({ + ...base, + outcome: 'error', + duration_ms: Date.now() - startedAt, + error_type: error instanceof Error ? error.name : 'Unknown', + }); + throw error; + } + }; +} + +export interface VideoUploadTelemetry { + readonly client: ITelemetryService; + readonly props?: Pick; +} diff --git a/packages/agent-core-v2/src/agent/media/sessionMediaStore.ts b/packages/agent-core-v2/src/agent/media/sessionMediaStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..4cfb1f7dd347fcd40c3c420e8e60163450ed3eff --- /dev/null +++ b/packages/agent-core-v2/src/agent/media/sessionMediaStore.ts @@ -0,0 +1,40 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface SessionMediaMaterializeInput { + readonly fileId: string; + readonly size: number; + readonly name: string; + readonly mimeType: string; + readonly stream: () => NodeJS.ReadableStream; + readonly signal?: AbortSignal; +} + +export interface SessionMediaReadRange { + readonly start: number; + readonly end: number; +} + +export interface SessionMediaFile { + readonly path?: string; + readonly name: string; + readonly mediaType: string; + readonly size: number; + readonly stream: (range?: SessionMediaReadRange) => AsyncIterable; +} + +export interface ISessionMediaStore { + readonly _serviceBrand: undefined; + + pathFor(fileId: string, ext: string): string | undefined; + + resolveDisplayPath(fileId: string): Promise; + + read(fileId: string): Promise<{ readonly data: Uint8Array; readonly name: string } | undefined>; + + open(fileId: string): Promise; + + materialize(input: SessionMediaMaterializeInput): Promise; +} + +export const ISessionMediaStore: ServiceIdentifier = + createDecorator('sessionMediaStore'); diff --git a/packages/agent-core-v2/src/agent/media/sessionMediaStoreService.ts b/packages/agent-core-v2/src/agent/media/sessionMediaStoreService.ts new file mode 100644 index 0000000000000000000000000000000000000000..2f538dbd869fca4787921ef42dfd24443e868682 --- /dev/null +++ b/packages/agent-core-v2/src/agent/media/sessionMediaStoreService.ts @@ -0,0 +1,153 @@ +import { extname } from 'node:path'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { isFileId } from '#/app/file/fileService'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { IFileSystemStorageService } from '#/persistence/interface/storage'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; + +import { + AUDIO_MIME_BY_SUFFIX, + IMAGE_MIME_BY_SUFFIX, + mediaExtensionForMime, + VIDEO_MIME_BY_SUFFIX, +} from './mediaRef'; +import { + ISessionMediaStore, + type SessionMediaFile, + type SessionMediaMaterializeInput, +} from './sessionMediaStore'; + +interface SessionMediaMetadata { + readonly version: 1; + readonly key: string; + readonly name: string; + readonly mediaType: string; +} + +export class SessionMediaStoreService implements ISessionMediaStore { + declare readonly _serviceBrand: undefined; + private readonly scope: string; + + constructor( + @ISessionContext sessionContext: ISessionContext, + @IFileSystemStorageService private readonly storage: IFileSystemStorageService, + @IAtomicDocumentStore private readonly documents: IAtomicDocumentStore, + ) { + this.scope = sessionContext.scope('media'); + } + + pathFor(fileId: string, ext: string): string | undefined { + if (!isFileId(fileId)) return undefined; + return this.storage.pathFor(this.scope, this.keyFor(fileId, ext)); + } + + async resolveDisplayPath(fileId: string): Promise { + if (!isFileId(fileId)) return undefined; + const key = await this.findKey(fileId); + if (key === undefined) return undefined; + return this.storage.pathFor(this.scope, key); + } + + async read( + fileId: string, + ): Promise<{ readonly data: Uint8Array; readonly name: string } | undefined> { + if (!isFileId(fileId)) return undefined; + const key = await this.findKey(fileId); + if (key === undefined) return undefined; + const data = await this.storage.read(this.scope, key); + return data === undefined ? undefined : { data, name: key }; + } + + async open(fileId: string): Promise { + if (!isFileId(fileId)) return undefined; + const storedMetadata = await this.documents.get(this.scope, this.metadataKey(fileId)); + const metadata = this.isMetadataFor(storedMetadata, fileId) ? storedMetadata : undefined; + const key = + metadata !== undefined && (await this.storage.size(this.scope, metadata.key)) !== undefined + ? metadata.key + : await this.findKey(fileId); + if (key === undefined) return undefined; + const size = await this.storage.size(this.scope, key); + if (size === undefined) return undefined; + return { + path: this.storage.pathFor(this.scope, key), + name: metadata?.name ?? key, + mediaType: metadata?.mediaType ?? this.mediaTypeForKey(key), + size, + stream: (range) => this.storage.readStream(this.scope, key, range), + }; + } + + async materialize(input: SessionMediaMaterializeInput): Promise { + if (!isFileId(input.fileId)) return undefined; + const ext = extname(input.name) || (mediaExtensionForMime(input.mimeType) ?? '.bin'); + const key = this.keyFor(input.fileId, ext); + const existingSize = await this.storage.size(this.scope, key); + if (existingSize !== input.size) { + const source = input.stream() as NodeJS.ReadableStream & AsyncIterable; + await this.storage.writeStream(this.scope, key, source, { + atomic: true, + signal: input.signal, + }); + } + await this.documents.set(this.scope, this.metadataKey(input.fileId), { + version: 1, + key, + name: input.name, + mediaType: input.mimeType, + }); + return this.storage.pathFor(this.scope, key); + } + + private keyFor(fileId: string, ext: string): string { + return `${fileId}${ext}`; + } + + private metadataKey(fileId: string): string { + return `meta/${fileId}.json`; + } + + private isMetadataFor(value: unknown, fileId: string): value is SessionMediaMetadata { + if (typeof value !== 'object' || value === null) return false; + const candidate = value as Partial; + return ( + candidate.version === 1 && + typeof candidate.key === 'string' && + (candidate.key === fileId || candidate.key.startsWith(`${fileId}.`)) && + !candidate.key.includes('/') && + !candidate.key.includes('\\') && + typeof candidate.name === 'string' && + candidate.name.length > 0 && + typeof candidate.mediaType === 'string' && + candidate.mediaType.length > 0 + ); + } + + private mediaTypeForKey(key: string): string { + const ext = extname(key).toLowerCase(); + return ( + IMAGE_MIME_BY_SUFFIX[ext] ?? + VIDEO_MIME_BY_SUFFIX[ext] ?? + AUDIO_MIME_BY_SUFFIX[ext] ?? + 'application/octet-stream' + ); + } + + private async findKey(fileId: string): Promise { + const keys = await this.storage.list(this.scope, fileId); + return keys.find( + (key) => + key === fileId || (key.startsWith(`${fileId}.`) && !key.includes('.tmp.')), + ); + } +} + +registerScopedService( + LifecycleScope.Session, + ISessionMediaStore, + SessionMediaStoreService, + ScopeActivation.OnScopeCreated, + 'media', +); diff --git a/packages/agent-core-v2/src/agent/media/videoUpload.ts b/packages/agent-core-v2/src/agent/media/videoUpload.ts new file mode 100644 index 0000000000000000000000000000000000000000..3e8e7a8d50044c6bd8a18c06875f5f7caa1cbb49 --- /dev/null +++ b/packages/agent-core-v2/src/agent/media/videoUpload.ts @@ -0,0 +1,25 @@ +import { VideoUploadUnsupportedError } from '#/llm-adapter/contract/errors'; +import { errorStatusCode } from '#human/llm/errors'; +import type { VideoURLPart } from '#human/llm/message'; +import type { Protocol } from '#/llm-adapter/protocol/protocol'; +import { ProtocolErrors } from '#/llm-adapter/protocol/errors'; + +export function isMediaUploadAuthError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false; + if ((error as { code?: unknown }).code === ProtocolErrors.codes.PROVIDER_AUTH_ERROR) return true; + const status = errorStatusCode(error); + return status === 401 || status === 403; +} + +export function isVideoUploadUnsupportedError(error: unknown): error is VideoUploadUnsupportedError { + return error instanceof VideoUploadUnsupportedError; +} + +export function inlineVideoSupportedForProtocol(protocol: Protocol): boolean { + return protocol !== 'openai' && protocol !== 'openai_responses'; +} + +export function inlineVideoPart(data: Uint8Array, mimeType: string): VideoURLPart { + const base64 = Buffer.from(data).toString('base64'); + return { type: 'video_url', videoUrl: { url: `data:${mimeType};base64,${base64}` } }; +} diff --git a/packages/agent-core-v2/src/agent/media/webp-dec-wasm.ts b/packages/agent-core-v2/src/agent/media/webp-dec-wasm.ts new file mode 100644 index 0000000000000000000000000000000000000000..e793f5076a02f492417561293a64161c3f84d27b --- /dev/null +++ b/packages/agent-core-v2/src/agent/media/webp-dec-wasm.ts @@ -0,0 +1,2 @@ +export const WEBP_DECODER_WASM_BASE64 = + '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AiABKAIMNgIMIAIgASgCEDYCECACIAEoAhQ2AhQgAiABKAIYNgIYIAIgASgCHDYCHCACIAEoAiA2AiAgAiABKAIkNgIkIAIgASgCKDYCKCACIAEoAiw2AiwgAiABKAIwNgIwIAIgASgCNDYCNCACIAEoAjg2AjggAiABKAI8NgI8IAFBQGshASACQUBrIgIgBU0NAAsLIAIgBE8NAQNAIAIgASgCADYCACABQQRqIQEgAkEEaiICIARJDQALDAELIANBBEkEQCAAIQIMAQsgACADQQRrIgRLBEAgACECDAELIAAhAgNAIAIgAS0AADoAACACIAEtAAE6AAEgAiABLQACOgACIAIgAS0AAzoAAyABQQRqIQEgAkEEaiICIARNDQALCyACIANJBEADQCACIAEtAAA6AAAgAUEBaiEBIAJBAWoiAiADRw0ACwsgAAvyAgICfwF+AkAgAkUNACAAIAE6AAAgACACaiIDQQFrIAE6AAAgAkEDSQ0AIAAgAToAAiAAIAE6AAEgA0EDayABOgAAIANBAmsgAToAACACQQdJDQAgACABOgADIANBBGsgAToAACACQQlJDQAgAEEAIABrQQNxIgRqIgMgAUH/AXFBgYKECGwiATYCACADIAIgBGtBfHEiBGoiAkEEayABNgIAIARBCUkNACADIAE2AgggAyABNgIEIAJBCGsgATYCACACQQxrIAE2AgAgBEEZSQ0AIAMgATYCGCADIAE2AhQgAyABNgIQIAMgATYCDCACQRBrIAE2AgAgAkEUayABNgIAIAJBGGsgATYCACACQRxrIAE2AgAgBCADQQRxQRhyIgRrIgJBIEkNACABrUKBgICAEH4hBSADIARqIQEDQCABIAU3AxggASAFNwMQIAEgBTcDCCABIAU3AwAgAUEgaiEBIAJBIGsiAkEfSw0ACwsgAAuXKQELfyMAQRBrIgskAAJAAkACQAJAAkACQAJAAkACQCAAQfQBTQRAQaDbACgCACIGQRAgAEELakF4cSAAQQtJGyIFQQN2IgB2IgFBA3EEQAJAIAFBf3NBAXEgAGoiAkEDdCIBQcjbAGoiACABQdDbAGooAgAiASgCCCIERgRAQaDbACAGQX4gAndxNgIADAELIAQgADYCDCAAIAQ2AggLIAFBCGohACABIAJBA3QiAkEDcjYCBCABIAJqIgEgASgCBEEBcjYCBAwKCyAFQajbACgCACIHTQ0BIAEEQAJAQQIgAHQiAkEAIAJrciABIAB0cSIAQQAgAGtxaCIBQQN0IgBByNsAaiICIABB0NsAaigCACIAKAIIIgRGBEBBoNsAIAZBfiABd3EiBjYCAAwBCyAEIAI2AgwgAiAENgIICyAAIAVBA3I2AgQgACAFaiIIIAFBA3QiASAFayIEQQFyNgIEIAAgAWogBDYCACAHBEAgB0F4cUHI2wBqIQFBtNsAKAIAIQICfyAGQQEgB0EDdnQiA3FFBEBBoNsAIAMgBnI2AgAgAQwBCyABKAIICyEDIAEgAjYCCCADIAI2AgwgAiABNgIMIAIgAzYCCAsgAEEIaiEAQbTbACAINgIAQajbACAENgIADAoLQaTbACgCACIKRQ0BIApBACAKa3FoQQJ0QdDdAGooAgAiAigCBEF4cSAFayEDIAIhAQNAAkAgASgCECIARQRAIAEoAhQiAEUNAQsgACgCBEF4cSAFayIBIAMgASADSSIBGyEDIAAgAiABGyECIAAhAQwBCwsgAigCGCEJIAIgAigCDCIERwRAQbDbACgCABogAigCCCIAIAQ2AgwgBCAANgIIDAkLIAJBFGoiASgCACIARQRAIAIoAhAiAEUNAyACQRBqIQELA0AgASEIIAAiBEEUaiIBKAIAIgANACAEQRBqIQEgBCgCECIADQALIAhBADYCAAwIC0F/IQUgAEG/f0sNACAAQQtqIgBBeHEhBUGk2wAoAgAiCEUNAEEAIAVrIQMCQAJAAkACf0EAIAVBgAJJDQAaQR8gBUH///8HSw0AGiAFQSYgAEEIdmciAGt2QQFxIABBAXRrQT5qCyIHQQJ0QdDdAGooAgAiAUUEQEEAIQAMAQtBACEAIAVBGSAHQQF2a0EAIAdBH0cbdCECA0ACQCABKAIEQXhxIAVrIgYgA08NACABIQQgBiIDDQBBACEDIAEhAAwDCyAAIAEoAhQiBiAGIAEgAkEddkEEcWooAhAiAUYbIAAgBhshACACQQF0IQIgAQ0ACwsgACAEckUEQEEAIQRBAiAHdCIAQQAgAGtyIAhxIgBFDQMgAEEAIABrcWhBAnRB0N0AaigCACEACyAARQ0BCwNAIAAoAgRBeHEgBWsiAiADSSEBIAIgAyABGyEDIAAgBCABGyEEIAAoAhAiAQR/IAEFIAAoAhQLIgANAAsLIARFDQAgA0Go2wAoAgAgBWtPDQAgBCgCGCEHIAQgBCgCDCICRwRAQbDbACgCABogBCgCCCIAIAI2AgwgAiAANgIIDAcLIARBFGoiASgCACIARQRAIAQoAhAiAEUNAyAEQRBqIQELA0AgASEGIAAiAkEUaiIBKAIAIgANACACQRBqIQEgAigCECIADQALIAZBADYCAAwGCyAFQajbACgCACIETQRAQbTbACgCACEAAkAgBCAFayIBQRBPBEAgACAFaiICIAFBAXI2AgQgACAEaiABNgIAIAAgBUEDcjYCBAwBCyAAIARBA3I2AgQgACAEaiIBIAEoAgRBAXI2AgRBACECQQAhAQtBqNsAIAE2AgBBtNsAIAI2AgAgAEEIaiEADAgLIAVBrNsAKAIAIgJJBEBBrNsAIAIgBWsiATYCAEG42wBBuNsAKAIAIgAgBWoiAjYCACACIAFBAXI2AgQgACAFQQNyNgIEIABBCGohAAwIC0EAIQAgBUEvaiIDAn9B+N4AKAIABEBBgN8AKAIADAELQYTfAEJ/NwIAQfzeAEKAoICAgIAENwIAQfjeACALQQxqQXBxQdiq1aoFczYCAEGM3wBBADYCAEHc3gBBADYCAEGAIAsiAWoiBkEAIAFrIghxIgEgBU0NB0HY3gAoAgAiBARAQdDeACgCACIHIAFqIgkgB00NCCAEIAlJDQgLAkBB3N4ALQAAQQRxRQRAAkACQAJAAkBBuNsAKAIAIgQEQEHg3gAhAANAIAQgACgCACIHTwRAIAcgACgCBGogBEsNAwsgACgCCCIADQALC0EAECIiAkF/Rg0DIAEhBkH83gAoAgAiAEEBayIEIAJxBEAgASACayACIARqQQAgAGtxaiEGCyAFIAZPDQNB2N4AKAIAIgAEQEHQ3gAoAgAiBCAGaiIIIARNDQQgACAISQ0ECyAGECIiACACRw0BDAULIAYgAmsgCHEiBhAiIgIgACgCACAAKAIEakYNASACIQALIABBf0YNASAGIAVBMGpPBEAgACECDAQLQYDfACgCACICIAMgBmtqQQAgAmtxIgIQIkF/Rg0BIAIgBmohBiAAIQIMAwsgAkF/Rw0CC0Hc3gBB3N4AKAIAQQRyNgIACyABECIhAkEAECIhACACQX9GDQUgAEF/Rg0FIAAgAk0NBSAAIAJrIgYgBUEoak0NBQtB0N4AQdDeACgCACAGaiIANgIAQdTeACgCACAASQRAQdTeACAANgIACwJAQbjbACgCACIDBEBB4N4AIQADQCACIAAoAgAiASAAKAIEIgRqRg0CIAAoAggiAA0ACwwEC0Gw2wAoAgAiAEEAIAAgAk0bRQRAQbDbACACNgIAC0EAIQBB5N4AIAY2AgBB4N4AIAI2AgBBwNsAQX82AgBBxNsAQfjeACgCADYCAEHs3gBBADYCAANAIABBA3QiAUHQ2wBqIAFByNsAaiIENgIAIAFB1NsAaiAENgIAIABBAWoiAEEgRw0AC0Gs2wAgBkEoayIAQXggAmtBB3FBACACQQhqQQdxGyIBayIENgIAQbjbACABIAJqIgE2AgAgASAEQQFyNgIEIAAgAmpBKDYCBEG82wBBiN8AKAIANgIADAQLIAAtAAxBCHENAiABIANLDQIgAiADTQ0CIAAgBCAGajYCBEG42wAgA0F4IANrQQdxQQAgA0EIakEHcRsiAGoiATYCAEGs2wBBrNsAKAIAIAZqIgIgAGsiADYCACABIABBAXI2AgQgAiADakEoNgIEQbzbAEGI3wAoAgA2AgAMAwtBACEEDAULQQAhAgwDC0Gw2wAoAgAgAksEQEGw2wAgAjYCAAsgAiAGaiEBQeDeACEAAkACQAJAAkACQAJAA0AgASAAKAIARwRAIAAoAggiAA0BDAILCyAALQAMQQhxRQ0BC0Hg3gAhAANAIAMgACgCACIBTwRAIAEgACgCBGoiBCADSw0DCyAAKAIIIQAMAAsACyAAIAI2AgAgACAAKAIEIAZqNgIEIAJBeCACa0EHcUEAIAJBCGpBB3EbaiIHIAVBA3I2AgQgAUF4IAFrQQdxQQAgAUEIakEHcRtqIgYgBSAHaiIFayEAIAMgBkYEQEG42wAgBTYCAEGs2wBBrNsAKAIAIABqIgA2AgAgBSAAQQFyNgIEDAMLQbTbACgCACAGRgRAQbTbACAFNgIAQajbAEGo2wAoAgAgAGoiADYCACAFIABBAXI2AgQgACAFaiAANgIADAMLIAYoAgQiA0EDcUEBRgRAIANBeHEhCQJAIANB/wFNBEAgBigCDCIBIAYoAggiAkYEQEGg2wBBoNsAKAIAQX4gA0EDdndxNgIADAILIAIgATYCDCABIAI2AggMAQsgBigCGCEIAkAgBiAGKAIMIgJHBEAgBigCCCIBIAI2AgwgAiABNgIIDAELAkAgBkEUaiIDKAIAIgENACAGQRBqIgMoAgAiAQ0AQQAhAgwBCwNAIAMhBCABIgJBFGoiAygCACIBDQAgAkEQaiEDIAIoAhAiAQ0ACyAEQQA2AgALIAhFDQACQCAGKAIcIgFBAnRB0N0AaiIEKAIAIAZGBEAgBCACNgIAIAINAUGk2wBBpNsAKAIAQX4gAXdxNgIADAILIAhBEEEUIAgoAhAgBkYbaiACNgIAIAJFDQELIAIgCDYCGCAGKAIQIgEEQCACIAE2AhAgASACNgIYCyAGKAIUIgFFDQAgAiABNgIUIAEgAjYCGAsgBiAJaiIGKAIEIQMgACAJaiEACyAGIANBfnE2AgQgBSAAQQFyNgIEIAAgBWogADYCACAAQf8BTQRAIABBeHFByNsAaiEBAn9BoNsAKAIAIgJBASAAQQN2dCIAcUUEQEGg2wAgACACcjYCACABDAELIAEoAggLIQAgASAFNgIIIAAgBTYCDCAFIAE2AgwgBSAANgIIDAMLQR8hAyAAQf///wdNBEAgAEEmIABBCHZnIgFrdkEBcSABQQF0a0E+aiEDCyAFIAM2AhwgBUIANwIQIANBAnRB0N0AaiEBAkBBpNsAKAIAIgJBASADdCIEcUUEQEGk2wAgAiAEcjYCACABIAU2AgAMAQsgAEEZIANBAXZrQQAgA0EfRxt0IQMgASgCACECA0AgAiIBKAIEQXhxIABGDQMgA0EddiECIANBAXQhAyABIAJBBHFqIgQoAhAiAg0ACyAEIAU2AhALIAUgATYCGCAFIAU2AgwgBSAFNgIIDAILQazbACAGQShrIgBBeCACa0EHcUEAIAJBCGpBB3EbIgFrIgg2AgBBuNsAIAEgAmoiATYCACABIAhBAXI2AgQgACACakEoNgIEQbzbAEGI3wAoAgA2AgAgAyAEQScgBGtBB3FBACAEQSdrQQdxG2pBL2siACAAIANBEGpJGyIBQRs2AgQgAUHo3gApAgA3AhAgAUHg3gApAgA3AghB6N4AIAFBCGo2AgBB5N4AIAY2AgBB4N4AIAI2AgBB7N4AQQA2AgAgAUEYaiEAA0AgAEEHNgIEIABBCGohAiAAQQRqIQAgAiAESQ0ACyABIANGDQMgASABKAIEQX5xNgIEIAMgASADayICQQFyNgIEIAEgAjYCACACQf8BTQRAIAJBeHFByNsAaiEAAn9BoNsAKAIAIgFBASACQQN2dCICcUUEQEGg2wAgASACcjYCACAADAELIAAoAggLIQEgACADNgIIIAEgAzYCDCADIAA2AgwgAyABNgIIDAQLQR8hACACQf///wdNBEAgAkEmIAJBCHZnIgBrdkEBcSAAQQF0a0E+aiEACyADIAA2AhwgA0IANwIQIABBAnRB0N0AaiEBAkBBpNsAKAIAIgRBASAAdCIGcUUEQEGk2wAgBCAGcjYCACABIAM2AgAMAQsgAkEZIABBAXZrQQAgAEEfRxt0IQAgASgCACEEA0AgBCIBKAIEQXhxIAJGDQQgAEEddiEEIABBAXQhACABIARBBHFqIgYoAhAiBA0ACyAGIAM2AhALIAMgATYCGCADIAM2AgwgAyADNgIIDAMLIAEoAggiACAFNgIMIAEgBTYCCCAFQQA2AhggBSABNgIMIAUgADYCCAsgB0EIaiEADAULIAEoAggiACADNgIMIAEgAzYCCCADQQA2AhggAyABNgIMIAMgADYCCAtBrNsAKAIAIgAgBU0NAEGs2wAgACAFayIBNgIAQbjbAEG42wAoAgAiACAFaiICNgIAIAIgAUEBcjYCBCAAIAVBA3I2AgQgAEEIaiEADAMLQdDiAEEwNgIAQQAhAAwCCwJAIAdFDQACQCAEKAIcIgBBAnRB0N0AaiIBKAIAIARGBEAgASACNgIAIAINAUGk2wAgCEF+IAB3cSIINgIADAILIAdBEEEUIAcoAhAgBEYbaiACNgIAIAJFDQELIAIgBzYCGCAEKAIQIgAEQCACIAA2AhAgACACNgIYCyAEKAIUIgBFDQAgAiAANgIUIAAgAjYCGAsCQCADQQ9NBEAgBCADIAVqIgBBA3I2AgQgACAEaiIAIAAoAgRBAXI2AgQMAQsgBCAFQQNyNgIEIAQgBWoiAiADQQFyNgIEIAIgA2ogAzYCACADQf8BTQRAIANBeHFByNsAaiEAAn9BoNsAKAIAIgFBASADQQN2dCIDcUUEQEGg2wAgASADcjYCACAADAELIAAoAggLIQEgACACNgIIIAEgAjYCDCACIAA2AgwgAiABNgIIDAELQR8hACADQf///wdNBEAgA0EmIANBCHZnIgBrdkEBcSAAQQF0a0E+aiEACyACIAA2AhwgAkIANwIQIABBAnRB0N0AaiEBAkACQCAIQQEgAHQiBnFFBEBBpNsAIAYgCHI2AgAgASACNgIADAELIANBGSAAQQF2a0EAIABBH0cbdCEAIAEoAgAhBQNAIAUiASgCBEF4cSADRg0CIABBHXYhBiAAQQF0IQAgASAGQQRxaiIGKAIQIgUNAAsgBiACNgIQCyACIAE2AhggAiACNgIMIAIgAjYCCAwBCyABKAIIIgAgAjYCDCABIAI2AgggAkEANgIYIAIgATYCDCACIAA2AggLIARBCGohAAwBCwJAIAlFDQACQCACKAIcIgBBAnRB0N0AaiIBKAIAIAJGBEAgASAENgIAIAQNAUGk2wAgCkF+IAB3cTYCAAwCCyAJQRBBFCAJKAIQIAJGG2ogBDYCACAERQ0BCyAEIAk2AhggAigCECIABEAgBCAANgIQIAAgBDYCGAsgAigCFCIARQ0AIAQgADYCFCAAIAQ2AhgLAkAgA0EPTQRAIAIgAyAFaiIAQQNyNgIEIAAgAmoiACAAKAIEQQFyNgIEDAELIAIgBUEDcjYCBCACIAVqIgQgA0EBcjYCBCADIARqIAM2AgAgBwRAIAdBeHFByNsAaiEAQbTbACgCACEBAn9BASAHQQN2dCIFIAZxRQRAQaDbACAFIAZyNgIAIAAMAQsgACgCCAshBiAAIAE2AgggBiABNgIMIAEgADYCDCABIAY2AggLQbTbACAENgIAQajbACADNgIACyACQQhqIQALIAtBEGokACAAC7wCAQV/IAAgARATIQUgACgCACECIAAoAgQhBgJAIAAoAggiAUEATg0AIAAoAgwiAyAAKAIUSQRAIAMoAAAhBCAAIANBA2o2AgwgACACQRh0IARBCHZBgP4DcSAEQRh0IARBgP4DcUEIdHJyQQh2ciICNgIAIAFBGGohAQwBCyAAKAIQIANLBEAgACADQQFqNgIMIAAgAUEIaiIBNgIIIAAgAy0AACACQQh0ciICNgIADAELIAAoAhgEQEEAIQEMAQsgAEEBNgIYIAAgAkEIdCICNgIAIAFBCGohAQsgACABAn8gAiABdiIEIAZBAXZB////B3EiA0sEQCAAIANBf3MgAXQgAmo2AgAgBiADawwBCyADQQFqCyICZ0EYcyIBazYCCCAAIAIgAXRBAWs2AgRBACAFayAFIAMgBEkbC10BA39BBCECAn8gACABckEDcUUEQEEAIAAoAgAgASgCAEYNARoLAkADQCAALQAAIgMgAS0AACIERw0BIAFBAWohASAAQQFqIQAgAkEBayICDQALQQAPCyADIARrCwt0AQF/IAJFBEAgACgCBCABKAIERg8LIAAgAUYEQEEBDwsgASgCBCICLQAAIQECQCAAKAIEIgMtAAAiAEUNACAAIAFHDQADQCACLQABIQEgAy0AASIARQ0BIAJBAWohAiADQQFqIQMgACABRg0ACwsgACABRgv/AwERfyABQQNsIQ5BACABayEPIAFBfWwhEEEAIAFBAnRrIRFBACABQQF0IhJrIRMgBEEBdEEBciEUA0AgAyEEAkAgACATaiIKLQAAIgggACABaiIMLQAAIgtrIhVB78kAai0AACAAIA9qIg0tAAAiAyAALQAAIglrQe/JAGotAABBAnRqIBRKDQAgACARai0AACAAIBBqLQAAIgdrQe/JAGotAAAgBUoNACAHIAhrQe/JAGotAAAgBUoNACAIIANrQe/JAGotAAAiFiAFSg0AIAAgDmotAAAgACASai0AACIHa0HvyQBqLQAAIAVKDQAgByALa0HvyQBqLQAAIAVKDQAgCyAJa0HvyQBqLQAAIhcgBUoNACAJIANrQQNsIQcCfyAGIBZOIAYgF05xRQRAIA0gAyAHIBVB/DdqLAAAaiIDQQNqQQN1QfDAAGosAABqQe/DAGotAAA6AAAgACEMIAkgA0EEakEDdUHwwABqLAAAawwBCyAKIAggB0EEakEDdUHwwABqLAAAIghBAWpBAXUiCmpB78MAai0AADoAACANIAdBA2pBA3VB8MAAaiwAACADakHvwwBqLQAAOgAAIAAgCSAIa0HvwwBqLQAAOgAAIAsgCmsLIQMgDCADQe/DAGotAAA6AAALIARBAWshAyAAIAJqIQAgBEEBSw0ACwv0AQEHfwJAIAFBAEwNACAAQUBrIQYDQCAGKAIAIAAoAjhIBEAgACgCGEEATA0CCyAAKAIEBEAgACAAKQJMQiCJNwJMCyAAIAJBhOEAQYDhACAAKAIAGygCABEDAAJAIAAoAgQNACAAKAI0IAAoAghsQQBMDQAgACgCTCEHIAAoAlAhCEEAIQUDQCAHIAVBAnQiCWoiCiAKKAIAIAggCWooAgBqNgIAIAVBAWoiBSAAKAI0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akG0AWogBSANIAogBhAnIANBAUYNAQNAIAAgAkEBayIDQRRsakG0AWogBSANIAYgBhAnIAJBAUshBCADIQIgBA0ACwwBCyAGIApGDQAgBiAKIAggCWxBAnQQFBoLIA8gEGohAwJAIA5BAEwNAEEAIQlBACECIA5BBE8EQCAOQXxxIQ8DQCACIANqIAYgAkECdGooAgBBCHY6AAAgAyACQQFyIgRqIAYgBEECdGooAgBBCHY6AAAgAyACQQJyIgRqIAYgBEECdGooAgBBCHY6AAAgAyACQQNyIgRqIAYgBEECdGooAgBBCHY6AAAgAkEEaiICIA9HDQALCyAOQQNxIgRFDQADQCACIANqIAYgAkECdGooAgBBCHY6AAAgAkEBaiECIAlBAWoiCSAERw0ACwsgCygCDCIEBEAgCygCjAEhAiAIQQFxBH8gAiADIAMgByAEQQJ0QbDgAGooAgARAQAgBUEBaiEFIAMiAiAHagUgAwshBCAIQQFHBEADQCACIAQgBCAHIAsoAgxBAnRBsOAAaigCABEBACAEIAQgB2oiAiACIAcgCygCDEECdEGw4ABqKAIAEQEAIAIgB2ohBCACIQMgBUECaiIFIA1HDQALCyALIAM2AowBCyAKIAAoAmQiCSAIbEECdGohCiANIQUgDCAIayIMQQBKDQALCyAAIAE2AmwgACABNgJ0C+IBAQR/IAAEfyAALQAABUEACyEAAkAgA0EATA0AIANBA3EhBQJAIANBBEkEQEEAIQMMAQsgA0F8cSEHQQAhAwNAIAIgA2ogASADai0AACAAaiIAOgAAIAIgA0EBciIEaiABIARqLQAAIABqIgA6AAAgAiADQQJyIgRqIAEgBGotAAAgAGoiADoAACACIANBA3IiBGogASAEai0AACAAaiIAOgAAIANBBGoiAyAHRw0ACwsgBUUNAANAIAIgA2ogASADai0AACAAaiIAOgAAIANBAWohAyAGQQFqIgYgBUcNAAsLC88CAQR/AkAgAARAIANBAEwNASADQQFHBEAgA0EBcSEFIANBfnEhBwNAIAIgBGogASAEai0AACAAIARqLQAAajoAACACIARBAXIiA2ogASADai0AACAAIANqLQAAajoAACAEQQJqIgQgB0cNAAsgBUUNAgsgAiAEaiABIARqLQAAIAAgBGotAABqOgAADwsgA0EA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AigCGCACKAIcajYCGCACIAIoAkQgAigCSGo2AkQgAiACKAJAQQFqNgJAIAEoAhwiAigCGEEATARAQYjhACEDAkACQCACKAIEDQBBjOEAIQMgAigCFA0AIAIoAjQgAigCCGxBAEwNASACKAJMIQRBACEDA0AgAigCRCADaiAEIANBAnQiCGooAgA6AAAgAigCTCIEIAhqQQA2AgAgA0EBaiIDIAIoAjQgAigCCGxIDQALDAELIAIgAygCABEAAAsgAiACKAIYIAIoAhxqNgIYIAIgAigCRCACKAJIajYCRCACIAIoAkBBAWo2AkALIAEoAiAiAigCGEEATARAQYjhACEDAkACQCACKAIEDQBBjOEAIQMgAigCFA0AIAIoAjQgAigCCGxBAEwNASACKAJMIQRBACEDA0AgAigCRCADaiAEIANBAnQiCGooAgA6AAAgAigCTCIEIAhqQQA2AgAgA0EBaiIDIAIoAjQgAigCCGxIDQALDAELIAIgAygCABEAAAsgAiACKAIYIAIoAhxqNgIYIAIgAigCRCACKAJIajYCRCACIAIoAkBBAWo2AkAgASgCICECCyABKAIYIgMoAkQgASgCHCgCRCACKAJEIAwgAygCNCAOEQIAIAZBAWohBiAMIAsoAhRqIQwgASgCGCICQUBrKAIAIAIoAjhIDQALCyAGIAlqIQkgByAKSA0ACyAJC+kCAQl/IAEoAgAiBCgCHCIGIAQoAiwiAyABKAIQIghsaiEFAkAgACgCaCIHBEAgACgCECICQQBMDQEgBCgCICEJIAEoAiQhAyAAKAIAIQYgBCgCECEKQQAhAANAIAcgAyACIAcgBhAbIgsgBmxqIQcgAxAkIABqIQAgAiALayICQQBKDQALIABBAEwNASAKIAggCWxqIAQoAiAgBSAEKAIsIAEoAiQoAjQgAEEBEDFBAA8LIAZFDQAgAkEATA0AIAAoAmAhASACQQhPBEAgAkF4cSEEQQAhAANAIAVB/wEgARAVIANqQf8BIAEQFSADakH/ASABEBUgA2pB/wEgARAVIANqQf8BIAEQFSADakH/ASABEBUgA2pB/wEgARAVIANqQf8BIAEQFSADaiEFIABBCGoiACAERw0ACwsgAkEHcSICRQ0AQQAhAANAIAVB/wEgARAVIANqIQUgAEEBaiIAIAJHDQALC0EAC7MCAQd/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6OcAKAIAIgAEQEHo5wAgACgCCDYCACAAKAIEIAAoAgARAAAgABASDAELC0Hh5wBBADoAAAsGAEGAggQLJAEBf0HE4gAoAgAiAARAA0AgACgCABEKACAAKAIEIgANAAsLC4UBAQN/AkAgACgCBCICIgBBA3EEQANAIAAtAABFDQIgAEEBaiIAQQNxDQALCwNAIAAiAUEEaiEAIAEoAgAiA0F/cyADQYGChAhrcUGAgYKEeHFFDQALA0AgASIAQQFqIQEgAC0AAA0ACwsgACACa0EBaiIAEBYiAQR/IAEgAiAAEBQFQQALC9MBAQF+IAAgAC0A3wEgAEEZay0AACAALQC/ASAAQRprLQAAIAAtAJ8BIABBG2stAAAgAC0AfyAAQRxrLQAAIAAtAF8gAEEday0AACAALQA/IABBHmstAAAgAC0AHyAAQR9rLQAAIABBIGstAAAgAEEBay0AAGpqampqampqampqampqakEIakEEdq1C/wGDQoGChIiQoMCAAX4iATcA4AEgACABNwDAASAAIAE3AKABIAAgATcAgAEgACABNwBgIAAgATcAQCAAIAE3ACAgACABNwAAC9cIARJ/IABB78MAIABBIWstAABrIgIgAEEBay0AAGoiASAAQSBrIgstAAAiA2otAAA6AAAgACABIABBH2siDC0AACIEai0AADoAASAAIAEgAEEeayINLQAAIgVqLQAAOgACIAAgASAAQR1rIg4tAAAiBmotAAA6AAMgACABIABBHGsiDy0AACIHai0AADoABCAAIAEgAEEbayIQLQAAIghqLQAAOgAFIAAgASAAQRprIhEtAAAiCWotAAA6AAYgACABIABBGWsiEi0AACIKai0AADoAByAAIAogAiAALQAfaiIBai0AADoAJyAAIAEgCWotAAA6ACYgACABIAhqLQAAOgAlIAAgASAHai0AADoAJCAAIAEgBmotAAA6ACMgACABIAVqLQAAOgAiIAAgASAEai0AADoAISAAIAEgA2otAAA6ACAgACAKIAIgAC0AP2oiAWotAAA6AEcgACABIAlqLQAAOgBGIAAgASAIai0AADoARSAAIAEgB2otAAA6AEQgACABIAZqLQAAOgBDIAAgASAFai0AADoAQiAAIAEgBGotAAA6AEEgACABIANqLQAAOgBAIAAgAiAALQBfaiIBIAstAAAiA2otAAA6AGAgACABIAwtAAAiBGotAAA6AGEgACABIA0tAAAiBWotAAA6AGIgACABIA4tAAAiBmotAAA6AGMgACABIA8tAAAiB2otAAA6AGQgACABIBAtAAAiCGotAAA6AGUgACABIBEtAAAiCWotAAA6AGYgACABIBItAAAiCmotAAA6AGcgACAKIAIgAC0Af2oiAWotAAA6AIcBIAAgASAJai0AADoAhgEgACABIAhqLQAAOgCFASAAIAEgB2otAAA6AIQBIAAgASAGai0AADoAgwEgACABIAVqLQAAOgCCASAAIAEgBGotAAA6AIEBIAAgASADai0AADoAgAEgACAKIAIgAC0AnwFqIgFqLQAAOgCnASAAIAEgCWotAAA6AKYBIAAgASAIai0AADoApQEgACABIAdqLQAAOgCkASAAIAEgBmotAAA6AKMBIAAgASAFai0AADoAogEgACABIARqLQAAOgChASAAIAEgA2otAAA6AKABIAAgAiAALQC/AWoiASALLQAAIgtqLQAAOgDAASAAIAEgDC0AACIDai0AADoAwQEgACABIA0tAAAiDGotAAA6AMIBIAAgASAOLQAAIgRqLQAAOgDDASAAIAEgDy0AACINai0AADoAxAEgACABIBAtAAAiBWotAAA6AMUBIAAgASARLQAAIg5qLQAAOgDGASAAIAEgEi0AACIGai0AADoAxwEgACAGIAIgAC0A3wFqIgJqLQAAOgDnASAAIAIgDmotAAA6AOYBIAAgAiAFai0AADoA5QEgACACIA1qLQAAOgDkASAAIAIgBGotAAA6AOMBIAAgAiAMai0AADoA4gEgACACIANqLQAAOgDhASAAIAIgC2otAAA6AOABC0gBAX4gACAAQSBrKQAAIgE3AOABIAAgATcAwAEgACABNwCgASAAIAE3AIABIAAgATcAYCAAIAE3AEAgACABNwAgIAAgATcAAAu0AQAgACAAMQAfQoGChIiQoMCAAX43ACAgACAAMQA/QoGChIiQoMCAAX43AEAgACAAMQBfQoGChIiQoMCAAX43AGAgACAAMQB/QoGChIiQoMCAAX43AIABIAAgADEAnwFCgYKEiJCgwIABfjcAoAEgACAAMQC/AUKBgoSIkKDAgAF+NwDAASAAIAAxAN8BQoGChIiQoMCAAX43AOABIAAgAEEBazEAAEKBgoSIkKDAgAF+NwAAC4sBAQF+IAAgAC0A3wEgAC0AvwEgAC0AnwEgAC0AfyAALQBfIAAtAD8gAEEBay0AACAALQAfampqampqakEEakEDdq1C/wGDQoGChIiQoMCAAX4iATcA4AEgACABNwDAASAAIAE3AKABIAAgATcAgAEgACABNwBgIAAgATcAQCAAIAE3ACAgACABNwAAC50BAQF+IAAgAEEZay0AACAAQRprLQAAIABBG2stAAAgAEEcay0AACAAQR1rLQAAIABBHmstAAAgAEEgay0AACAAQR9rLQAAampqampqakEEakEDdq1C/wGDQoGChIiQoMCAAX4iATcA4AEgACABNwDAASAAIAE3AKABIAAgATcAgAEgACABNwBgIAAgATcAQCAAIAE3ACAgACABNwAACwcAIAARDwALhgEAIABCgIGChIiQoMCAfzcA4AEgAEKAgYKEiJCgwIB/NwDAASAAQoCBgoSIkKDAgH83AKABIABCgIGChIiQoMCAfzcAgAEgAEKAgYKEiJCgwIB/NwBgIABCgIGChIiQoMCAfzcAQCAAQoCBgoSIkKDAgH83ACAgAEKAgYKEiJCgwIB/NwAAC48EAQF+IAAgAEERay0AACAALQDfAyAAQRJrLQAAIAAtAL8DIABBE2stAAAgAC0AnwMgAEEUay0AACAALQD/AiAAQRVrLQAAIAAtAN8CIABBFmstAAAgAC0AvwIgAEEXay0AACAALQCfAiAAQRhrLQAAIAAtAP8BIABBGWstAAAgAC0A3wEgAEEaay0AACAALQC/ASAAQRtrLQAAIAAtAJ8BIABBHGstAAAgAC0AfyAAQR1rLQAAIAAtAF8gAEEeay0AACAALQA/IABBH2stAAAgAC0AHyAAQQFrLQAAIABBIGstAABqampqampqampqampqampqampqampqampqampqampqQRBqQQV2rUL/AYNCgYKEiJCgwIABfiIBNwAIIAAgATcAACAAIAE3ACAgACABNwAoIAAgATcAQCAAIAE3AEggACABNwBgIAAgATcAaCAAIAE3AIABIAAgATcAiAEgACABNwCgASAAIAE3AKgBIAAgATcAwAEgACABNwDIASAAIAE3AOgBIAAgATcA4AEgACABNwCIAiAAIAE3AIACIAAgATcAqAIgACABNwCgAiAAIAE3AMgCIAAgATcAwAIgACABNwDoAiAAIAE3AOACIAAgATcAiAMgACABNwCAAyAAIAE3AKgDIAAgATcAoAMgACABNwDIAyAAIAE3AMADIAAgATcA6AMgACABNwDgAwukAwETfyAAQRFrIQMgAEESayEEIABBE2shBSAAQRRrIQYgAEEVayEHIABBFmshCCAAQRdrIQkgAEEYayEKIABBGWshCyAAQRprIQwgAEEbayENIABBHGshDiAAQR1rIQ8gAEEeayEQIABBH2shESAAQSBrIRJB78MAIABBIWstAABrIRMDQCAAIBMgAEEBay0AAGoiASASLQAAai0AADoAACAAIAEgES0AAGotAAA6AAEgACABIBAtAABqLQAAOgACIAAgASAPLQAAai0AADoAAyAAIAEgDi0AAGotAAA6AAQgACABIA0tAABqLQAAOgAFIAAgASAMLQAAai0AADoABiAAIAEgCy0AAGotAAA6AAcgACABIAotAABqLQAAOgAIIAAgASAJLQAAai0AADoACSAAIAEgCC0AAGotAAA6AAogACABIActAABqLQAAOgALIAAgASAGLQAAai0AADoADCAAIAEgBS0AAGotAAA6AA0gACABIAQtAABqLQAAOgAOIAAgASADLQAAai0AADoADyAAQSBqIQAgAkEBaiICQRBHDQALC5cCAgJ+AX8gACAAQSBrIgMpAAAiATcAACAAIAE3ACAgACABNwBAIAAgATcAYCAAIAE3AIABIAAgATcAoAEgACABNwDAASAAIAE3AOABIAAgAykACCIBNwAIIAAgATcAKCAAIAE3AEggACABNwBoIAAgATcAiAEgACABNwCoASAAIAE3AMgBIAAgATcA6AEgACADKQAIIgE3AIgCIAAgAykAACICNwCAAiAAIAE3AKgCIAAgAjcAoAIgACABNwDIAiAAIAI3AMACIAAgATcA6AIgACACNwDgAiAAIAI3AIADIAAgATcAiAMgACABNwCoAyAAIAI3AKADIAAgAjcAwAMgACABNwDIAyAAIAE3AOgDIAAgAjcA4AMLigQBAX4gACAAMQAfQoGChIiQoMCAAX4iATcAICAAIAE3ACggACAAMQA/QoGChIiQoMCAAX4iATcAQCAAIAE3AEggACAAMQBfQoGChIiQoMCAAX4iATcAYCAAIAE3AGggACAAMQB/QoGChIiQoMCAAX4iATcAgAEgACABNwCIASAAIAAxAJ8BQoGChIiQoMCAAX4iATcAqAEgACABNwCgASAAIABBAWsxAABCgYKEiJCgwIABfiIBNwAAIAAgATcACCAAIAAxAL8BQoGChIiQoMCAAX4iATcAyAEgACABNwDAASAAIAAxAN8BQoGChIiQoMCAAX4iATcA6AEgACABNwDgASAAIAAxAP8BQoGChIiQoMCAAX4iATcAiAIgACABNwCAAiAAIAAxAJ8CQoGChIiQoMCAAX4iATcAqAIgACABNwCgAiAAIAAxAL8CQoGChIiQoMCAAX4iATcAyAIgACABNwDAAiAAIAAxAN8CQoGChIiQoMCAAX4iATcA6AIgACABNwDgAiAAIAAxAP8CQoGChIiQoMCAAX4iATcAiAMgACABNwCAAyAAIAAxAJ8DQoGChIiQoMCAAX4iATcAqAMgACABNwCgAyAAIAAxAL8DQoGChIiQoMCAAX4iATcAyAMgACABNwDAAyAAIAAxAN8DQoGChIiQoMCAAX4iATcA6AMgACABNwDgAwv/AgEBfiAAIAAtAN8DIAAtAL8DIAAtAJ8DIAAtAP8CIAAtAN8CIAAtAL8CIAAtAJ8CIAAtAP8BIAAtAN8BIAAtAL8BIAAtAJ8BIAAtAH8gAC0AXyAALQA/IABBAWstAAAgAC0AH2pqampqampqampqampqakEIakEEdq1C/wGDQoGChIiQoMCAAX4iATcAACAAIAE3AAggACABNwAoIAAgATcAICAAIAE3AEggACABNwBAIAAgATcAaCAAIAE3AGAgACABNwCIASAAIAE3AIABIAAgATcAqAEgACABNwCgASAAIAE3AMgBIAAgATcAwAEgACABNwDoASAAIAE3AOABIAAgATcAiAIgACABNwCAAiAAIAE3AKgCIAAgATcAoAIgACABNwDIAiAAIAE3AMACIAAgATcA6AIgACABNwDgAiAAIAE3AIgDIAAgATcAgAMgACABNwCoAyAAIAE3AKADIAAgATcAyAMgACABNwDAAyAAIAE3AOgDIAAgATcA4AMLoQMBAX4gACAAQRFrLQAAIABBEmstAAAgAEETay0AACAAQRRrLQAAIABBFWstAAAgAEEWay0AACAAQRdrLQAAIABBGGstAAAgAEEZay0AACAAQRprLQAAIABBG2stAAAgAEEcay0A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IAIgAigC/BE2AqQBAkAgA0ECRgRAIAIoAoASIQMgAiACKAKwATYCgBIgAiADNgKwAQwBCyACICwQKwsgAQRAIAIoAtQRIQEgAiACKAKsATYC1BEgAiABNgKsAQsgAigChAEiAQRAIAIoAogBIAIoAowBIAERBQAhASACIAIoApABIAFFcjYCkAELIAIgAigCmAFBAWoiAUEAIAEgAigCnAFHGzYCmAEMAgsgAiABNgKoASACIAIoAvwRNgKkASACICwQKyACIAlBJGoQLw0BC0EAIQMgAigCAA0FIAJBgxE2AgggAkIGNwIADAULIAIgAigC/BFBAWoiGTYC/BEgGSACKAK0AkgNAAsLIAIoApQBQQBKBEBBACEDIAIoApABDQMLQQEhAwwCC0EAIQMgAigCAA0BIAJBthE2AgggAkIHNwIADAELQQAhAyACKAIADQAgAkG0EDYCCCACQgE3AgALQQEhASACKAKUAUEASgRAIAIoApABRSEBCyAJKAJYIgQEQCAJQSRqIAQRAAALIAEgA3ENAgsgAkEANgKAASACKAK4EhASIAJCADcCuBIgAigCqBIiAQRAIAEoAhQiAwRAIAMQHSADEBILIAEQEgsgAkEANgKoEiACKALwERASIAJCADcCDCACQgA3AvARIAJCADcCFCACQgA3AhwgAkEANgIkIAJBADYCBAsgAigCACEBDAELQQAhASACQQA2AgQLIAJBADYCgAEgAigCuBIQEiACQgA3ArgSIAIoAqgSIgMEQCADKAIUIgQEQCAEEB0gBBASCyADEBILIAJBADYCqBIgAigC8BEQEgsgAhASIAEEQCAMKAJgIgNFDQEgAygCDEEATARAIAMoAlAQEgsgA0EANgJQDAELQQAhASAMKAJ0IgNFDQAgAygCMEUNACAMKAJgIgNFBEBBAiEBDAELIAMoAghBAWshBiADKAIQIQgCQCADKAIAQQpNBEAgAyAIIAYgA0EUaiIEKAIAIgNsajYCEAwBCyADQQAgAygCICIEazYCICADQQAgAygCJCIKazYCJCADQQAgAygCKCIFazYCKCADIAggBCAGbGo2AhAgAyADKAIUIAogBkEBdSIEbGo2AhQgAyADKAIYIAQgBWxqNgIYIAMoAhwiCEUNASADIAggA0EsaiIEKAIAIgMgBmxqNgIcCyAEQQAgA2s2AgALIAlBsAFqJAAgAQ0AIAwoAhgiAUUNAAJAQaDfAC0AAA0AQaDfAEEBOgAAQZDfAEGACBAENgIAQQYQJkGU3wBB/QoQBDYCAEEHECZBoN8ALQAADQBBoN8AQQE6AABBkN8AQYAIEAQ2AgBBBhAmQZTfAEH9ChAENgIAQQcQJgsgDCABNgIMIAwgKiArbEECdDYCCEGQ3wAoAgBBAUG8EiAMQQhqIgQQByIDEAggDCArNgIYIAwgKjYCECAMIAM2AgggAEGU3wAoAgBBA0HAEiAEEAc2AgAgAxADIAEQEgwBCyAAQQI2AgALIAxBwAFqJAALvwIBB38gACAAQR9rLQAAIgVBAWoiASAAQR5rLQAAIgJqQQF2IgM6AEAgACABIABBIGstAAAiBmpBAXY6AAAgACACIABBHWstAAAiAWpBAWpBAXYiBDoAQSAAIAM6AAEgACABIABBHGstAAAiA2pBAWpBAXYiBzoAQiAAIAQ6AAIgACAHOgADIAAgBSABQQJqIgRqIAJBAXRqQQJ2Igc6AGAgACAGIAJBAmoiAmogBUEBdGpBAnY6ACAgACADIAIgAUEBdGpqQQJ2IgU6AGEgACAHOgAhIABBGWstAAAhBiAAQRprLQAAIQIgACAAQRtrLQAAIgEgBCADQQF0ampBAnYiBDoAYiAAIAU6ACIgACAGIAEgAkEBdGpqQQJqQQJ2OgBjIAAgAiADIAFBAXRqakECakECdjoAQyAAIAQ6ACMLsAIBCX8gACAALQAfIgMgAC0APyIEakEBakEBdiICOgBiIAAgBCAALQBfIgdqQQFqQQF2OgBgIAAgAjoAQCAAIABBAWstAAAiBkEBaiIBIABBIWstAAAiAmpBAXYiBToAIiAAIAEgA2pBAXYiAToAQiAAIAU6AAAgACABOgAgIAAgAEEgay0AACIBIAZBAmoiBSACQQF0ampBAnYiCDoAIyAAIABBHmstAAAgASAAQR9rLQAAIglBAXRqakECakECdjoAAyAAIAkgAiABQQF0ampBAmpBAnY6AAIgACACIANBAmoiASAGQQF0ampBAnYiAjoAQyAAIAg6AAEgACAEIAVqIANBAXRqQQJ2IgM6AGMgACACOgAhIAAgASAHaiAEQQF0akECdjoAYSAAIAM6AEEL2QEBBn8gACAALQBfIgE6AGMgACABOgBiIAAgAToAYSAAIAE6AGAgACAALQAfIgRBAWoiAyAALQA/IgJqQQF2IgU6ACAgACADIABBAWstAAAiBmpBAXY6AAAgACABIAJqQQFqQQF2IgM6AEAgACAFOgACIAAgAzoAIiAAIAEgBGogAkEBdGpBAmpBAnYiAzoAISAAIAYgAkECaiICaiAEQQF0akECdjoAASAAIAEgAmogAUEBdGpBAnYiAjoAQSAAIAM6AAMgACACOgAjIAAgAToAQyAAIAE6AEILbgEBfyAAIAAtAF8gAEEday0AACAALQA/IABBHmstAAAgAC0AHyAAQR9rLQAAIABBIGstAAAgAEEBay0AAGpqampqampBBGpBA3ZB/wFxQYGChAhsIgE2AGAgACABNgBAIAAgATYAICAAIAE2AAALpAIBBn8gAEHvwwAgAEEhay0AAGsiAiAAQQFrLQAAaiIBIABBIGstAAAiA2otAAA6AAAgACABIABBH2stAAAiBGotAAA6AAEgACABIABBHmstAAAiBWotAAA6AAIgACABIABBHWstAAAiBmotAAA6AAMgACAGIAIgAC0AH2oiAWotAAA6ACMgACABIAVqLQAAOgAiIAAgASAEai0AADoAISAAIAEgA2otAAA6ACAgACAGIAIgAC0AP2oiAWotAAA6AEMgACABIAVqLQAAOgBCIAAgASAEai0AADoAQSAAIAEgA2otAAA6AEAgACAGIAIgAC0AX2oiAmotAAA6AGMgACACIAVqLQAAOgBiIAAgAiAEai0AADoAYSAAIAIgA2otAAA6AGAL4gEBBn8gACAAQRxrLQAAIABBHmstAAAiAkECaiIDIABBHWstAAAiAUEBdGpqQQJ2IgQ6AGMgACABIABBH2stAAAiBUECaiIGIAJBAXRqakECdiICOgBiIAAgAyAAQSBrLQAAIgFqIAVBAXRqQQJ2IgM6AGEgACAGIABBIWstAABqIAFBAXRqQQJ2IgE6AGAgACAEOgBDIAAgAjoAQiAAIAM6AEEgACABOgBAIAAgBDoAIyAAIAI6ACIgACADOgAhIAAgAToAICAAIAQ6AAMgACACOgACIAAgAzoAASAAIAE6AAALpgIBBX8gACAALQBfIAAtAB8iAUECaiIDIAAtAD8iAkEBdGpqQQJ2OgBgIAAgAiAAQQFrLQAAIgRBAmoiBSABQQF0ampBAnYiAToAYSAAIAE6AEAgACAAQSFrLQAAIgIgAyAEQQF0ampBAnYiAToAYiAAIAE6AEEgACABOgAgIAAgBSAAQSBrLQAAIgNqIAJBAXRqQQJ2IgE6AGMgACABOgBCIAAgAToAISAAIAE6AAAgAEEday0AACEFIABBHmstAAAhASAAIAIgAEEfay0AACIEaiADQQF0akECakECdiICOgBDIAAgAjoAIiAAIAI6AAEgACABIANqIARBAXRqQQJqQQJ2IgI6ACMgACACOgACIAAgBCAFaiABQQF0akECakECdjoAAwujAgEFfyAAIABBHWstAAAiAkECaiIFIABBH2stAAAiA2ogAEEeay0AACIBQQF0akECdiIEOgAgIAAgAUECaiIBIABBIGstAABqIANBAXRqQQJ2OgAAIAAgAEEcay0AACIDIAEgAkEBdGpqQQJ2IgE6AEAgACAEOgABIAAgAToAISAAIABBG2stAAAiBCAFIANBAXRqakECdiICOgBgIAAgAToAAiAAIAI6AEEgACACOgAiIAAgAjoAAyAAIABBGmstAAAiAiADIARBAXRqakECakECdiIDOgBhIAAgAEEZay0AACIBIAQgAkEBdGpqQQJqQQJ2IgQ6AGIgACADOgAjIAAgAzoAQiAAIAEgAmogAUEBdGpBAmpBAnY6AGMgACAEOgBDCy8AIAAgARAhIABBIGogAUEEahAhIABBQGsgAUGAAWoQISAAQeAAaiABQYQBahAhC08BAX8gAC8BACICBEAgAiABECALIAAvASAiAgRAIAIgAUEEahAgCyAALwFAIgIEQCACIAFBgAFqECALIAAvAWAiAARAIAAgAUGEAWoQIAsLqQIBBH8jAEEQayICJAAgASgCACIEQfD///8HSQRAAkACQCAEQQtPBEAgBEEPckEBaiIFEDghAyACIAVBgICAgHhyNgIIIAIgAzYCACACIAQ2AgQgAyAEaiEFDAELIAIgBDoACyACIARqIQUgAiEDIARFDQELIAMgAUEEaiAEEBQaCyAFQQA6AAAgAkEMaiACIAARAwAgAigCDBAIIAIoAgwiABADIAIsAAtBAEgEQCACKAIAEBILIAJBEGokACAADwtB2AAQFkHQAGoiA0Hk2gA2AgAgA0GQ2QA2AgBBGRA4IgFBADYCCCABQoyAgIDAATcCACABQQxqIgBBlwopAAA3AAUgAUGSCikAADcADCADIAA2AgQgA0HA2QA2AgAgA0Hg2QBBPBANAAvkEgETfwJAIAEgACgCbCIDayICQQBMDQAgACgCCCIIKAIAIQsgACgCECAAKAJkIgUgA2xBAnRqIQYgACgCFCEJAkAgACgCsAEiBEEASgRAIAAgBEEBayICQRRsakG0AWogAyABIAYgCRAnIARBAUYNAQNAIAAgAkEBayIGQRRsakG0AWogAyABIAkgCRAnIAJBAUshBCAGIQIgBA0ACwwBCyAGIAlGDQAgCSAGIAIgBWxBAnQQFBoLIAgoAlgiBiABIAEgBkobIgYgCCgCVCICIAAoAmwiAyACIANKIgUbIgRMDQAgCCAGIARrIgY2AhAgCCAEIAJrNgIIIAggCCgCUCAIKAJMIgprIgQ2AgwgCSALQQJ0IhAgAiADa2xBACAFG2ogCkECdGohCyAAKAIMIgIoAgAiEUEKTQRAIAIoAhAgAigCFCINIAAoAnRsaiEKAkAgCCgCXARAIAZBAEwEQEEAIQgMAgtBACEJQQAhCANAIAsgCSAQbGoiAyAQIAAoAowCIgIoAiwgAigCICIEIAIoAhhqQQFrIARtIgQgBiAJayICIAIgBEobEEEgACgCjAIgAiADIBAQGyAJaiEJQQAhBQJAIAAoAowCIgNBQGsiDigCACADKAI4Tg0AIAogCCANbGohEyADKAI0IQwgAygCRCESA0AgAygCGEEASg0BQYjhACECAkACQCADKAIEDQBBjOEAIQIgAygCFA0AIAMoAjQgAygCCGxBAEwNASADKAJMIQdBACECA0AgAygCRCACaiAHIAJBAnQiBGooAgA6AAAgAygCTCIHIARqQQA2AgAgAkEBaiICIAMoAjQgAygCCGxIDQALDAELIAMgAigCABEAAAsgAyADKAIYIAMoAhxqNgIYIAMgAygCRCADKAJIajYCRCAOIA4oAgBBAWo2AgBBACECIAxBAEoEQANAIBIgAkECdGoiFCgCACIEQf///3dNBEBBACEHIBQgBEGAgIAITwR/IARBgICAeHFBgICAeCAEQRh2biIHIARB/wFxbEGAgIAEakEYdnIgByAEQQh2Qf8BcWxBgICABGpBEHZBgP4DcXIgByAEQRB2Qf8BcWxBgICABGpBCHZBgID8B3FyBUEACzYCAAsgAkEBaiICIAxHDQALCyASIAwg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b2wAZW1zY3JpcHRlbjo6dmFsAGJhZCBwYXJ0aXRpb24gbGVuZ3RoAHVuc2lnbmVkIGxvbmcAc3RkOjp3c3RyaW5nAGJhc2ljX3N0cmluZwBzdGQ6OnN0cmluZwBzdGQ6OnUxNnN0cmluZwBzdGQ6OnUzMnN0cmluZwBkb3VibGUAZGVjb2RlAEJhZCBjb2RlIHdvcmQAdm9pZABGcmFtZSBzZXR1cCBmYWlsZWQASW1hZ2VEYXRhAFZQOFgAV0VCUABWUDhMAE9LAEFMUEgAUklGRgBlbXNjcmlwdGVuOjptZW1vcnlfdmlldzxzaG9ydD4AZW1zY3JpcHRlbjo6bWVtb3J5X3ZpZXc8dW5zaWduZWQgc2hvcnQ+AGVtc2NyaXB0ZW46Om1lbW9yeV92aWV3PGludD4AZW1zY3JpcHRlbjo6bWVtb3J5X3ZpZXc8dW5zaWduZWQgaW50PgBlbXNjcmlwdGVuOjptZW1vcnlfdmlldzxmbG9hdD4AZW1zY3JpcHRlbjo6bWVtb3J5X3ZpZXc8dWludDhfdD4AZW1zY3JpcHRlbjo6bWVtb3J5X3ZpZXc8aW50OF90PgBlbXNjcmlwdGVuOjptZW1vcnlfdmlldzx1aW50MTZfdD4AZW1zY3JpcHRlbjo6bWVtb3J5X3ZpZXc8aW50MTZfdD4AZW1zY3JpcHRlbjo6bWVtb3J5X3ZpZXc8dWludDMyX3Q+AGVtc2NyaXB0ZW46Om1lbW9yeV92aWV3PGludDMyX3Q+AGVtc2NyaXB0ZW46Om1lbW9yeV92aWV3PGNoYXI+AGVtc2NyaXB0ZW46Om1lbW9yeV92aWV3PHVuc2lnbmVkIGNoYXI+AHN0ZDo6YmFzaWNfc3RyaW5nPHVuc2lnbmVkIGNoYXI+AGVtc2NyaXB0ZW46Om1lbW9yeV92aWV3PHNpZ25lZCBjaGFyPgBlbXNjcmlwdGVuOjptZW1vcnlfdmlldzxsb25nPgBlbXNjcmlwdGVuOjptZW1vcnlfdmlldzx1bnNpZ25lZCBsb25nPgBlbXNjcmlwdGVuOjptZW1vcnlfdmlldzxkb3VibGU+AEluY29ycmVjdCBrZXlmcmFtZSBwYXJhbWV0ZXJzLgBUcnVuY2F0ZWQgaGVhZGVyLgBubyBtZW1vcnkgZHVyaW5nIGZyYW1lIGluaXRpYWxpemF0aW9uLgBOb3QgYSBrZXkgZnJhbWUuAEZyYW1lIG5vdCBkaXNwbGF5YWJsZS4AT3V0cHV0IGFib3J0ZWQuAFByZW1hdHVyZSBlbmQtb2YtZmlsZSBlbmNvdW50ZXJlZC4AUHJlbWF0dXJlIGVuZC1vZi1wYXJ0aXRpb24wIGVuY291bnRlcmVkLgBDb3VsZCBub3QgZGVjb2RlIGFscGhhIGRhdGEuAG51bGwgVlA4SW8gcGFzc2VkIHRvIFZQOEdldEhlYWRlcnMoKQBWUDggAAAAwCgAAHwnAABpaWkA6CsAAGlpAAA4KQAAwCgAAOgrAADoKwAAAAAAAP///////////////////////////////////////////7D2////////////3/H8///////////5/f3////////////0/P//////////6v7+///////////9///////////////2/v//////////7/3+///////////+//7////////////4/v//////////+//+///////////////////////////9/v//////////+/7+///////////+//7////////////+/f/+////////+v/+//7////////+/////////////////////////////////////////////////////////9n/////////////4fzx/f///v/////q+vH6/f/9/v/////+////////////3/7+///////////u/f7+///////////4/v//////////+f7////////////////////////////9////////////9/7////////////////////////////9/v///////////P/////////////////////////////+/v///////////f/////////////////////////////+/f//////////+v/////////////+/////////////////////////////////////////////////////////7r7+v//////////6vv0/v/////////7+/P9/v/+///////9/v//////////7P3+///////////7/f3+/v/////////+/v///////////v7+///////////////////////////+/////////////v7////////////+/////////////////////////////v////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////j/////////////+v78/v/////////4/vn9///////////9/f//////////9v39///////////8/vv+/v/////////+/P//////////+P79///////////9//7+///////////7/v//////////9fv+///////////9/f7////////////7/f///////////P3+/////////////v/////////////8////////////+f/+//////////////7//////////////f//////////+v///////////////////////////////////////////v///////////////////////////4CAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgP2I/v/k24CAgICAvYHy/+PV/9uAgIBqfuP81tH//4CAgAFi+P/s4v//gICAtYXu/t3q/5qAgIBOhsr3xrT/24CAgAG5+f/z/4CAgICAuJb3/+zggICAgIBNbtj/7OaAgICAgAFl+//x/4CAgICAqovx/OzR//+AgIAldMTz5P///4CAgAHM/v/1/4CAgICAz6D6/+6AgICAgIBmZ+f/06uAgICAgAGY/P/w/4CAgICAsYfz/+rhgICAgIBQgdP/wuCAgICAgAEB/4CAgICAgICA9gH/gICAgICAgID/gICAgICAgICAgMYj7d/Bu6KgkZs+gy3G3ayw3J383QFEL5LQlafdov/fgAGV8f/d4P//gICAuI3q/d7c/8eAgIBRY7XysL75yv//gAGB6P3WxfLE//+AY3nS+snG/8qAgIAXW6Pyqrv30v//gAHI9v/q/4CAgICAbbLx/+f1//+AgIAsgsn9zcD//4CAgAGE7/vb0f+lgICAXojh+9q+//+AgIAWZK71uqH/x4CAgAG2+f/o64CAgICAfI/x/+PqgICAgIAjTbX7wdP/zYCAgAGd9//s5///gICAeY3r/+Hj//+AgIAtY7z7w9n/4ICAgAEB+//V/4CAgICAywH4//+AgICAgICJAbH/4P+AgICAgP0J+PvP0P/AgICArw3g88G5+cb//4BJEavdobPsp//qgAFf9/3Ut///gICA71r0+tPR//+AgICbTcP4vMP//4CAgAEY7/va2//NgICAyTPb/8S6gICAgIBFLr7vydr/5ICAgAG/+///gICAgICA36X5/9X/gICAgICNfPj//4CAgICAgAEQ+P//gICAgICAviTm/+z/gICAgICVAf+AgICAgICAgAHi/4CAgICAgICA98D/gICAgICAgIDwgP+AgICAgICAgAGG/P//gICAgICA1T76//+AgICAgIA3Xf+AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgMoY1eu6v9yg8K//fia26Km45K7/u4A9Lorbl7Lwqv/YgAFw5vrHv/ef//+Apm3k/NPX/66AgIAnTaLorLT1sv//gAE03PbGx/nc//+AfEq/87fB+t3//4AYR4Lbmqrztv//gAG24fnb8P/ggICAlZbi/NjN/6uAgIAcbKryt8L+3///gAFR5vzMy//AgICAe2bR97zE/+mAgIAUX5nzpK3/y4CAgAHe+P/Y1YCAgICAqK/2/OvN//+AgIAvdNf/09T//4CAgAF57P3U1v//gICAjVTV/MnK/9uAgIAqUKDworn/zYCAgAEB/4CAgICAgICA9AH/gICAgICAgIDuAf+AgICAgICAgOd4MFlzcXiYcJizQH6qdi5GX69Fj1BVUkibZzg6CqvavRENmHIaEaMswxUKrXkYUMMaPixAVZBHCiar1ZAiGqouNxOIoCHORz8UCHJy0AwJ4lEoC2C2VB0QJIa3WYliZWqllEi7ZIKdbyBLUEJmp2NKPijqgCk1CbLxjRoIa0orGpJJpjEXnUEmaaAzNB9zgGhPDBvZ/1cRB1dERyxyMw+6Fy8pDm62txURwkItGWbFvRcSFlhYk5YqLi3EzStht3VVJiOzPSc1yFcaFSvoqzgiM2hyZh1dTSccVas6pVpiQCIWdM4XIiumSWs2IBozAVErH0QZahZAqyThciITFWaEvBBMfD4STl9VOTIwM8FlI5/Xb1kubzyUH6zb5BUSb3BxTVWz/yZ4cigqAcT10QoZbVgrHYym1SUrmj0/HptDLUQB0WRQCCuaATMaR45OThD/gCLFqykoBWbTtwQB3TMyEajRwBcZUoofJKsbpiYs5UNXOqlScxo7sz87WrQ7pl1JmigoFXSP0SInry8PELci3zEtty4RIbcGYg8gtzkuFhiAATYRJUEgSXMcgBeAzSgDCXMzwBIG31clCXM7TUAVL2g3LNoJNjWC4kBaRs0oKRcaOTY5cLgFKSam1R4iGoWYdAoghicTNd0aciBJ/x8JQeoCDwF2SUsgDDPA/6ArM1gfI0NmVTe6VTgVF287zS0lwDcmRnxJZgEiYn1iKlhoVXWvUl9UNVmAZHFlLUtPey8zgFGrATkRBUdmOTUpMSYhDXk5SRoBVSkKQ4pNblovcnMVAgpm/6YXBmUdEApVgGXEGjkSCmZm1SIUK3UUDySjgEQBGmY9RyUiNR/zwEU8RyZJdxzeJUQtgCIBLwv1qz4RE0aSVTc+RiUrJZpko1WgAT8JXIgcQCDJVUsPCQlA/7h3EFYGHAVA/xn4ATgIEYSJ/zd0gDoPFFKHORp5KKQyH4mahRkj2jNnLIODex8GnlYoQIeU4C23gBYaEYPwmg4B0S0QFVtA3gcBxTgVJ5s8ihdm1VMMDTbA/0QvHFUaVVWAgCCSqxILBz+QqwQE9iMbCpKuqwwagL5QI2O0UH42LVV+L1ewMykUIGVLgIt2knSAVTgpD7DsVSUJPkceEXd2/xESimUmPIo3RisajpIkEx6r/2EbFIotPT7bAVG8QCApFHWXjhQVo3ATDD3DgDAEGABBoSoLEQH/Av4DBAb9Bfz7+gf5CPj3AEHAKgu1BQQFBgcICQoKCwwNDg8QERESExQUFRUWFhcXGBkZGhscHR4fICEiIyQlJSYnKCkqKywtLi4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xMTU5PUFFSU1RVVldYWVtdX2BiZGVmaGpsbnBydHZ6fH6AgoSGiIqMj5GUl5qdBAAFAAYABwAIAAkACgALAAwADQAOAA8AEAARABIAEwAUABUAFgAXABgAGQAaABsAHAAdAB4AHwAgACEAIgAjACQAJQAmACcAKAApACoAKwAsAC0ALgAvADAAMQAyADMANAA1ADYANwA4ADkAOgA8AD4AQABCAEQARgBIAEoATABOAFAAUgBUAFYAWABaAFwAXgBgAGIAZABmAGgAagBsAG4AcAByAHQAdwB6AH0AgACDAIYAiQCMAI8AkgCVAJgAmwCeAKEApACnAKoArQCxALUAuQC9AMEAxQDJAM0A0QDVANkA3QDhAOUA6gDvAPUA+QD+AAMBCAENARIBFwEcAQgHBgQEAgICAQEBAQACCAAAAAQACAAMAIAAhACIAIwAAAEEAQgBDAGAAYQBiAGMAQABBAgFAgMGCQwNCgcLDg8QFwAAFBcAABkXAAAfFwAArZSMALCbjIcAtJ2NhoIA/v7z5sSxmYyFgoEAAAAAAACKC4wLjguSC5oLqgvKCwoMjAyMDYwPjBMAAAAAAAAAABESAAECAwQFEAYHCAkKCwwNDg8CAwcDAwsAAAAAAAAAGAcXGSgGJykWGiYqOAU3ORUbNjolK0gER0kUHDU7RkokLFhFSzQ8A1dZEx1WWiMtRExVWzM9aAJnaRIeZmoiLlRcQ01lazI+eAF3eVNdER9kbEJOdnohL3V7MT9jbVJeAHR8QU8QIGJuMHN9UV9Acn5hb1Bxf2BwAwQDBAQCAgQEBAIBAQBBgDAL4BGAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp+goaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2+v8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4+Tl5ufo6err7O3u7/Dx8vP09fb3+Pn6+/z9/v8AAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1+f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f39/f38AAAAAAAAAAPDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDx8vP09fb3+Pn6+/z9/v8AAQIDBAUGBwgJCgsMDQ4PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PAEHwwwAL4wgBAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp+goaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2+v8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4+Tl5ufo6err7O3u7/Dx8vP09fb3+Pn6+/z9/v////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////8AAP/+/fz7+vn49/b19PPy8fDv7u3s6+rp6Ofm5eTj4uHg397d3Nva2djX1tXU09LR0M/OzczLysnIx8bFxMPCwcC/vr28u7q5uLe2tbSzsrGwr66trKuqqainpqWko6KhoJ+enZybmpmYl5aVlJOSkZCPjo2Mi4qJiIeGhYSDgoGAf359fHt6eXh3dnV0c3JxcG9ubWxramloZ2ZlZGNiYWBfXl1cW1pZWFdWVVRTUlFQT05NTEtKSUhHRkVEQ0JBQD8+PTw7Ojk4NzY1NDMyMTAvLi0sKyopKCcmJSQjIiEgHx4dHBsaGRgXFhUUExIREA8ODQwLCgkIBwYFBAMCAQABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp+goaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2+v8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4+Tl5ufo6err7O3u7/Dx8vP09fb3+Pn6+/z9/v8AAAAAAAEAAAADAAAABwAAAA8AAAAfAAAAPwAAAH8AAAD/AAAA/wEAAP8DAAD/BwAA/w8AAP8fAAD/PwAA/38AAP//AAD//wEA//8DAP//BwD//w8A//8fAP//PwD//38A////AEHgzAALjQ4wUuENhhizA8usX3dqYogcVVw4aCi4sxT4/oVKS7jdSZfz/GSJAlVcAAApStrBfg2rt0BZfVeSVHLKGU5pjNM4Ze4BDF91oTJS9jdUMiy7WrFXqg/nM/Vz2u5faOLMY3WDDplu7acwR8bZwE88FWtJ+gMUTwz7GlQyC5lzHMvXJgY3zG/Yd7ssKi92dd3MJWRhVLMkFYd9CqgUBCJnvx4UgxW0VuMC5XNvscpEQk0mKPuuunPt61AK+7ZqHQvUOg1oO9s1gx4IK5Vrznfw5YFRvDuFeJSUnwA87eUnTlN0M19fMjEyYmFzaWNfc3RyaW5nSWNOU18xMWNoYXJfdHJhaXRzSWNFRU5TXzlhbGxvY2F0b3JJY0VFRUUAAPQsAAA8JwAATlN0M19fMjEyYmFzaWNfc3RyaW5nSWhOU18xMWNoYXJfdHJhaXRzSWhFRU5TXzlhbGxvY2F0b3JJaEVFRUUAAPQsAACEJwAATlN0M19fMjEyYmFzaWNfc3RyaW5nSXdOU18xMWNoYXJfdHJhaXRzSXdFRU5TXzlhbGxvY2F0b3JJd0VFRUUAAPQsAADMJwAATlN0M19fMjEyYmFzaWNfc3RyaW5nSURzTlNfMTFjaGFyX3RyYWl0c0lEc0VFTlNfOWFsbG9jYXRvcklEc0VFRUUAAAD0LAAAFCgAAE5TdDNfXzIxMmJhc2ljX3N0cmluZ0lEaU5TXzExY2hhcl90cmFpdHNJRGlFRU5TXzlhbGxvY2F0b3JJRGlFRUVFAAAA9CwAAGAoAABOMTBlbXNjcmlwdGVuM3ZhbEUAAPQsAACsKAAATjEwZW1zY3JpcHRlbjExbWVtb3J5X3ZpZXdJY0VFAAD0LAAAyCgAAE4xMGVtc2NyaXB0ZW4xMW1lbW9yeV92aWV3SWFFRQAA9CwAAPAoAABOMTBlbXNjcmlwdGVuMTFtZW1vcnlfdmlld0loRUUAAPQsAAAYKQAATjEwZW1zY3JpcHRlbjExbWVtb3J5X3ZpZXdJc0VFAAD0LAAAQCkAAE4xMGVtc2NyaXB0ZW4xMW1lbW9yeV92aWV3SXRFRQAA9CwAAGgpAABOMTBlbXNjcmlwdGVuMTFtZW1vcnlfdmlld0lpRUUAAPQsAACQKQAATjEwZW1zY3JpcHRlbjExbWVtb3J5X3ZpZXdJakVFAAD0LAAAuCkAAE4xMGVtc2NyaXB0ZW4xMW1lbW9yeV92aWV3SWxFRQAA9CwAAOApAABOMTBlbXNjcmlwdGVuMTFtZW1vcnlfdmlld0ltRUUAAPQsAAAIKgAATjEwZW1zY3JpcHRlbjExbWVtb3J5X3ZpZXdJZkVFAAD0LAAAMCoAAE4xMGVtc2NyaXB0ZW4xMW1lbW9yeV92aWV3SWRFRQAA9CwAAFgqAABOMTBfX2N4eGFiaXYxMTZfX3NoaW1fdHlwZV9pbmZvRQAAAAA0LQAAgCoAACQtAABOMTBfX2N4eGFiaXYxMTdfX2NsYXNzX3R5cGVfaW5mb0UAAAA0LQAAsCoAAKQqAABOMTBfX2N4eGFiaXYxMTdfX3BiYXNlX3R5cGVfaW5mb0UAAAA0LQAA4CoAAKQqAABOMTBfX2N4eGFiaXYxMTlfX3BvaW50ZXJfdHlwZV9pbmZvRQA0LQAAECsAAAQrAAAAAAAAhCsAAGMAAABkAAAAZQAAAGYAAABnAAAATjEwX19jeHhhYml2MTIzX19mdW5kYW1lbnRhbF90eXBlX2luZm9FADQtAABcKwAApCoAAHYAAABIKwAAkCsAAGIAAABIKwAAnCsAAGMAAABIKwAAqCsAAGgAAABIKwAAtCsAAGEAAABIKwAAwCsAAHMAAABIKwAAzCsAAHQAAABIKwAA2CsAAGkAAABIKwAA5CsAAGoAAABIKwAA8CsAAGwAAABIKwAA/CsAAG0AAABIKwAACCwAAHgAAABIKwAAFCwAAHkAAABIKwAAICwAAGYAAABIKwAALCwAAGQAAABIKwAAOCwAAE4xMF9fY3h4YWJpdjEyMF9fc2lfY2xhc3NfdHlwZV9pbmZvRQAAAAA0LQAARCwAANQqAABTdDlleGNlcHRpb24AAAAAAAAAAKwsAAA8AAAAaAAAAGkAAABTdDExbG9naWNfZXJyb3IANC0AAJwsAABULQAAAAAAAOAsAAA8AAAAagAAAGkAAABTdDEybGVuZ3RoX2Vycm9yAAAAADQtAADMLAAArCwAAAAAAADUKgAAYwAAAGsAAABlAAAAZgAAAGwAAABtAAAAbgAAAG8AAABTdDl0eXBlX2luZm8AAAAA9CwAABQtAAAAAAAAbCwAAGMAAABwAAAAZQAAAGYAAABsAAAAcQAAAHIAAABzAAAA9CwAAHgsAAAAAAAAVC0AAHQAAAB1AAAAdgBB8NoACyrwMwEAdC0AAHgtAAB8LQAAgC0AAIQtAACILQAAjC0AAJAtAACULQAAmC0='; diff --git a/packages/agent-core-v2/src/agent/media/webp-decode.ts b/packages/agent-core-v2/src/agent/media/webp-decode.ts new file mode 100644 index 0000000000000000000000000000000000000000..253c07425e1be917782cd0609e2b5bc3d47f1229 --- /dev/null +++ b/packages/agent-core-v2/src/agent/media/webp-decode.ts @@ -0,0 +1,56 @@ +export interface DecodedWebp { + readonly data: Uint8ClampedArray; + readonly width: number; + readonly height: number; +} + +type WebpDecodeFn = (bytes: Uint8Array) => Promise; + +interface WasmGlobal { + readonly WebAssembly: { + compile(bytes: Uint8Array): Promise; + }; +} + +let decoderReady: Promise | null = null; + +async function loadDecoder(): Promise { + decoderReady ??= (async () => { + const [decodeModule, { WEBP_DECODER_WASM_BASE64 }] = await Promise.all([ + import('@jsquash/webp/decode.js'), + import('./webp-dec-wasm'), + ]); + const wasm = await (globalThis as unknown as WasmGlobal).WebAssembly.compile( + Buffer.from(WEBP_DECODER_WASM_BASE64, 'base64'), + ); + await decodeModule.init(wasm as never); + const decode = decodeModule.default; + return async (bytes: Uint8Array) => { + const copy = new Uint8Array(bytes); + return (await decode(copy.buffer)) as unknown as DecodedWebp; + }; + })(); + return decoderReady; +} + +export async function decodeWebp(bytes: Uint8Array): Promise { + const decode = await loadDecoder(); + return decode(bytes); +} + +export function isAnimatedWebp(bytes: Uint8Array): boolean { + if (bytes.length < 21) return false; + return ( + hasAscii(bytes, 'RIFF', 0) && + hasAscii(bytes, 'WEBP', 8) && + hasAscii(bytes, 'VP8X', 12) && + (bytes[20]! & 0x02) !== 0 + ); +} + +function hasAscii(bytes: Uint8Array, text: string, at: number): boolean { + for (let i = 0; i < text.length; i++) { + if (bytes[at + i] !== text.codePointAt(i)) return false; + } + return true; +} diff --git a/packages/agent-core-v2/src/agent/modeMutex/modeMutex.ts b/packages/agent-core-v2/src/agent/modeMutex/modeMutex.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d6f5825650a890872e940ab8393349948a99bb2 --- /dev/null +++ b/packages/agent-core-v2/src/agent/modeMutex/modeMutex.ts @@ -0,0 +1,8 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IAgentModeMutexService { + readonly _serviceBrand: undefined; +} + +export const IAgentModeMutexService: ServiceIdentifier = + createDecorator('agentModeMutexService'); diff --git a/packages/agent-core-v2/src/agent/modeMutex/modeMutexService.ts b/packages/agent-core-v2/src/agent/modeMutex/modeMutexService.ts new file mode 100644 index 0000000000000000000000000000000000000000..54f36afbe4a437e6ccc30db8a8da709136aaa1ac --- /dev/null +++ b/packages/agent-core-v2/src/agent/modeMutex/modeMutexService.ts @@ -0,0 +1,51 @@ +import { Disposable } from '#/_base/di/lifecycle'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { IEventBus } from '#/app/event/eventBus'; +import { LifecycleScope } from '#/app/scopes'; +import { IAgentPlanService } from '#/features/plan/plan'; +import { PlanModeEnter, planKey } from '#/features/plan/planOps'; +import { IAgentSwarmService } from '#/features/swarm/agent/swarm'; +import { SwarmModeEnter } from '#/features/swarm/swarmOps'; +import { IAgentTowerService } from '#/features/tower/tower'; +import { TowerModeEnter } from '#/features/tower/towerOps'; + +import { IAgentModeMutexService } from './modeMutex'; + +export class AgentModeMutexService extends Disposable implements IAgentModeMutexService { + declare readonly _serviceBrand: undefined; + + constructor( + @IAgentPlanService private readonly plan: IAgentPlanService, + @IAgentSwarmService private readonly swarm: IAgentSwarmService, + @IAgentTowerService private readonly tower: IAgentTowerService, + @IAgentStateService private readonly agentState: IAgentStateService, + @IEventBus eventBus: IEventBus, + ) { + super(); + this._register( + eventBus.subscribe(PlanModeEnter, () => { + if (this.tower.isActive) void this.tower.exit(); + }), + ); + this._register( + eventBus.subscribe(SwarmModeEnter, () => { + if (this.tower.isActive) void this.tower.exit(); + }), + ); + this._register( + eventBus.subscribe(TowerModeEnter, () => { + if (this.agentState.get(planKey).active) this.plan.exit(); + if (this.swarm.isActive) this.swarm.exit(); + }), + ); + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentModeMutexService, + AgentModeMutexService, + ScopeActivation.OnScopeCreated, + 'modeMutex', +); diff --git a/packages/agent-core-v2/src/agent/permissionGate/permissionGate.ts b/packages/agent-core-v2/src/agent/permissionGate/permissionGate.ts new file mode 100644 index 0000000000000000000000000000000000000000..428a31c04c1c107afc399920e9ce1a9716cc3889 --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionGate/permissionGate.ts @@ -0,0 +1,20 @@ +import { createDecorator } from "#/_base/di/instantiation"; +import type { + PermissionData +} from '#/agent/permissionPolicy/types'; +import type { + BeforeExecuteDecision, + ResolvedToolExecutionHookContext, +} from '#/agent/toolExecutor/toolHooks'; + +export interface IAgentPermissionGate { + readonly _serviceBrand: undefined; + + data(): PermissionData; + authorize( + context: ResolvedToolExecutionHookContext, + ): Promise; +} + +export const IAgentPermissionGate = + createDecorator('agentPermissionGate'); diff --git a/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts b/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts new file mode 100644 index 0000000000000000000000000000000000000000..c89d7f7397d77713e27f7aaf9dbfcb579aefdd7f --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts @@ -0,0 +1,95 @@ +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; +import { IAgentPermissionPolicyService } from '#/agent/permissionPolicy/permissionPolicy'; +import type { PermissionData } from '#/agent/permissionPolicy/types'; +import { IAgentPermissionRulesService } from '#/agent/permissionRules/permissionRules'; +import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; +import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; +import type { + BeforeExecuteDecision, + BeforeToolExecuteEvent, + ResolvedToolExecutionHookContext, +} from '#/agent/toolExecutor/toolHooks'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; + +import { IAgentPermissionGate } from './permissionGate'; + +export class AgentPermissionGate extends Service implements IAgentPermissionGate { + declare readonly _serviceBrand: undefined; + constructor( + @IAgentPermissionModeService private readonly modeService: IAgentPermissionModeService, + @IAgentPermissionRulesService private readonly rulesService: IAgentPermissionRulesService, + @IAgentPermissionPolicyService private readonly policyService: IAgentPermissionPolicyService, + @IAgentToolApprovalService private readonly toolApproval: IAgentToolApprovalService, + @ITelemetryService private readonly telemetry: ITelemetryService, + @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, + ) { + super(); + this._register(toolExecutor.onBeforeExecuteTool((event) => this.adjudicate(event))); + } + + data(): PermissionData { + return { + mode: this.modeService.mode, + rules: [...this.rulesService.rules], + }; + } + + private async adjudicate(event: BeforeToolExecuteEvent): Promise { + const evaluation = await this.policyService.evaluate(event); + if (evaluation === undefined) return; + this.telemetry.track2('permission_policy_decision', { + turn_id: event.turnId, + tool_call_id: event.toolCall.id, + policy_name: evaluation.policyName, + tool_name: event.toolCall.name, + permission_mode: this.modeService.mode, + decision: evaluation.result.kind, + ...evaluation.result.reason, + }); + const { result, policyName } = evaluation; + if (result.kind === 'ask') { + event.waitUntil(() => this.toolApproval.requestToolApproval(event, result, policyName)); + return; + } + if (result.kind === 'approve') { + event.pass(result.executionMetadata); + return; + } + const resolved = await this.toolApproval.resolvePermissionResolution(result, event, policyName); + if (resolved?.veto !== undefined) { + event.veto(resolved.veto); + } + } + + async authorize( + context: ResolvedToolExecutionHookContext, + ): Promise { + const evaluation = await this.policyService.evaluate(context); + if (evaluation === undefined) return undefined; + this.telemetry.track2('permission_policy_decision', { + turn_id: context.turnId, + tool_call_id: context.toolCall.id, + policy_name: evaluation.policyName, + tool_name: context.toolCall.name, + permission_mode: this.modeService.mode, + decision: evaluation.result.kind, + ...evaluation.result.reason, + }); + return this.toolApproval.resolvePermissionResolution( + evaluation.result, + context, + evaluation.policyName, + ); + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentPermissionGate, + AgentPermissionGate, + ScopeActivation.OnScopeCreated, + 'permissionGate', +); diff --git a/packages/agent-core-v2/src/agent/permissionMode/configSection.ts b/packages/agent-core-v2/src/agent/permissionMode/configSection.ts new file mode 100644 index 0000000000000000000000000000000000000000..7a6eb46b63e6b214765be6c8f844700a54bb32cd --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionMode/configSection.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +import { registerConfigSection } from '#/app/config/configSectionContributions'; + +export const DEFAULT_PERMISSION_MODE_SECTION = 'defaultPermissionMode'; + +export const DefaultPermissionModeSchema = z.enum(['manual', 'auto', 'yolo']); + +registerConfigSection(DEFAULT_PERMISSION_MODE_SECTION, DefaultPermissionModeSchema); diff --git a/packages/agent-core-v2/src/agent/permissionMode/injection/permission-mode-auto-enter-reminder.md b/packages/agent-core-v2/src/agent/permissionMode/injection/permission-mode-auto-enter-reminder.md new file mode 100644 index 0000000000000000000000000000000000000000..55fda48dbaef4336868149aa71b84a1abe75eaee --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionMode/injection/permission-mode-auto-enter-reminder.md @@ -0,0 +1,4 @@ +Auto permission mode is active. Tool approvals will be handled automatically while this mode remains enabled. + - Continue normally without pausing for approval prompts. + - Do NOT call AskUserQuestion while auto mode is active. Make a reasonable decision and continue without asking the user. + - ExitPlanMode is also approved automatically, without the user reviewing the plan. An auto-approved plan is NOT a signal from the user to start executing — follow the user's original instructions on whether to proceed. diff --git a/packages/agent-core-v2/src/agent/permissionMode/injection/permission-mode-auto-exit-reminder.md b/packages/agent-core-v2/src/agent/permissionMode/injection/permission-mode-auto-exit-reminder.md new file mode 100644 index 0000000000000000000000000000000000000000..8598cf5d71298bbfd16b9af011884497d4c77901 --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionMode/injection/permission-mode-auto-exit-reminder.md @@ -0,0 +1,2 @@ +Auto permission mode is no longer active. Tool approvals and permission checks are back to the current mode. + - Continue normally, but expect approval prompts or denials when a tool requires them. diff --git a/packages/agent-core-v2/src/agent/permissionMode/injection/permissionModeInjection.ts b/packages/agent-core-v2/src/agent/permissionMode/injection/permissionModeInjection.ts new file mode 100644 index 0000000000000000000000000000000000000000..4b38e2db7964bcf5c7d7b340237d39d09feaab5d --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionMode/injection/permissionModeInjection.ts @@ -0,0 +1,51 @@ +import { Service } from '#/_base/di/service'; +import { defineState } from '#/state/state'; +import type { IAgentReminderService } from '#/features/reminder/reminderService'; +import type { ContextInjectionContext } from '#/features/reminder/types'; +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 const permissionModeLastModeKey = defineState( + 'permissionMode.lastMode', + () => undefined as PermissionMode | undefined, +); + +export class PermissionModeInjection extends Service { + constructor( + private readonly permissionMode: Pick, + injector: IAgentReminderService, + @IAgentStateService private readonly states: IAgentStateService, + ) { + super(); + this.states.contributeState(permissionModeLastModeKey); + this._register( + injector.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; + if (currentMode === previousMode) { + if (injectedPositions.length > 0 || currentMode !== 'auto') return undefined; + return AUTO_MODE_ENTER_REMINDER; + } + this.lastMode = currentMode; + if (currentMode === 'auto') return AUTO_MODE_ENTER_REMINDER; + if (previousMode === 'auto') return AUTO_MODE_EXIT_REMINDER; + return undefined; + } +} diff --git a/packages/agent-core-v2/src/agent/permissionMode/permissionMode.ts b/packages/agent-core-v2/src/agent/permissionMode/permissionMode.ts new file mode 100644 index 0000000000000000000000000000000000000000..aaae863872bafea7a6501f00f9ef639a20bc12b6 --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionMode/permissionMode.ts @@ -0,0 +1,21 @@ +import { createDecorator } from "#/_base/di/instantiation"; +import type { Event } from '#/_base/event'; +import type { PermissionMode } from '#/agent/permissionPolicy/types'; + +export interface PermissionModeChangedContext { + readonly mode: PermissionMode; + readonly previousMode: PermissionMode; +} + +export interface IAgentPermissionModeService { + readonly _serviceBrand: undefined; + + readonly mode: PermissionMode; + setMode(mode: PermissionMode): void; + setModeAndBroadcast(mode: PermissionMode): void; + + readonly onDidChangeMode: Event; +} + +export const IAgentPermissionModeService = + createDecorator('agentPermissionModeService'); diff --git a/packages/agent-core-v2/src/agent/permissionMode/permissionModeOps.ts b/packages/agent-core-v2/src/agent/permissionMode/permissionModeOps.ts new file mode 100644 index 0000000000000000000000000000000000000000..efceeb8fea86c76885f8680b6ec5c7c0526b0151 --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionMode/permissionModeOps.ts @@ -0,0 +1,32 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { z } from 'zod'; + +import type { PermissionMode } from '#/agent/permissionPolicy/types'; +import { AgentEvent2 } from '#/app/event/event2'; +import { defineState } from '#/state/state'; + +const permissionSetModeSchema = z.object({ + agentId: z.string(), + mode: z.custom(), +}); + +export class PermissionSetMode extends AgentEvent2> { + static override readonly type = 'permission.set_mode'; + static override readonly durable = true; + static override readonly schema = permissionSetModeSchema; +} +export interface PermissionSetMode { + readonly agentId: string; + readonly mode: PermissionMode; +} + +export const permissionModeKey = defineState('permissionMode', (): PermissionMode => 'manual') + .replayable({ schema: z.custom() }) + .on(PermissionSetMode, (_s, e) => e.mode); + +export const permissionModeConfiguredKey = defineState( + 'permissionMode.configured', + (): boolean => false, +) + .replayable({ schema: z.custom() }) + .on(PermissionSetMode, () => true); diff --git a/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts b/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d3f9ad67a6b023d44f38cfbf01b68826673844b --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts @@ -0,0 +1,88 @@ +import type { PermissionMode } from '#/agent/permissionPolicy/types'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Emitter, type Event } from '#/_base/event'; +import { parseBooleanEnv } from '#/_base/utils/env'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { PermissionModeInjection } from '#/agent/permissionMode/injection/permissionModeInjection'; +import { IAgentReminderService } from '#/features/reminder/reminderService'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { + IAgentLifecycleService, + MAIN_AGENT_ID, +} from '#/session/agentLifecycle/agentLifecycle'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { IEventDispatcher } from '#/state/eventDispatcher'; +import { IAgentPermissionModeService, type PermissionModeChangedContext } from './permissionMode'; +import { + permissionModeConfiguredKey, + permissionModeKey, + PermissionSetMode, +} from './permissionModeOps'; + +export const PERMISSION_MODE_REMINDER_ENV = 'KIMI_CODE_PERMISSION_MODE_REMINDER'; + +export class AgentPermissionModeService extends Service implements IAgentPermissionModeService { + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeMode = this._register(new Emitter()); + readonly onDidChangeMode: Event = this._onDidChangeMode.event; + + constructor( + @IEventDispatcher private readonly dispatcher: IEventDispatcher, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, + @IAgentReminderService reminder: IAgentReminderService, + @ITelemetryService private readonly telemetry: ITelemetryService, + @IAgentStateService private readonly agentState: IAgentStateService, + @IBootstrapService bootstrap: IBootstrapService, + ) { + super(); + this.agentState.contributeState(permissionModeKey); + this.agentState.contributeState(permissionModeConfiguredKey); + if (parseBooleanEnv(bootstrap.getEnv(PERMISSION_MODE_REMINDER_ENV)) !== false) { + this._register(new PermissionModeInjection(this, reminder, this.agentState)); + } + } + + get mode(): PermissionMode { + return this.agentState.get(permissionModeKey); + } + + setMode(mode: PermissionMode): void { + const previousMode = this.mode; + const changed = mode !== previousMode; + if (!changed && this.agentState.get(permissionModeConfiguredKey)) return; + void this.dispatcher.dispatch( + new PermissionSetMode({ agentId: this.scopeContext.agentId, mode }), + ); + if (changed) this._onDidChangeMode.fire({ mode, previousMode }); + } + + setModeAndBroadcast(mode: PermissionMode): void { + const wasYolo = this.mode === 'yolo'; + const wasAuto = this.mode === 'auto'; + this.setMode(mode); + if (this.scopeContext.agentId === MAIN_AGENT_ID) { + this.agentLifecycle.broadcastPermissionMode(mode); + } + const yoloEnabled = this.mode === 'yolo'; + if (yoloEnabled !== wasYolo) { + this.telemetry.track2('yolo_toggle', { enabled: yoloEnabled }); + } + const afkEnabled = this.mode === 'auto'; + if (afkEnabled !== wasAuto) { + this.telemetry.track2('afk_toggle', { enabled: afkEnabled }); + } + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentPermissionModeService, + AgentPermissionModeService, + ScopeActivation.OnScopeCreated, + 'permissionMode', +); diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicy.ts b/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicy.ts new file mode 100644 index 0000000000000000000000000000000000000000..d5c7ea301807a9aefda49d39deae07625aea0a8f --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicy.ts @@ -0,0 +1,22 @@ +import { createDecorator } from "#/_base/di/instantiation"; +import type { + ResolvedToolExecutionHookContext +} from '#/agent/toolExecutor/toolHooks'; +import type { PermissionPolicyResult } from './types'; + + +export interface PermissionPolicyEvaluation { + readonly policyName: string; + readonly result: PermissionPolicyResult; +} + +export interface IAgentPermissionPolicyService { + readonly _serviceBrand: undefined; + + evaluate( + context: ResolvedToolExecutionHookContext, + ): Promise; +} + +export const IAgentPermissionPolicyService = + createDecorator('agentPermissionPolicyService'); diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts b/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts new file mode 100644 index 0000000000000000000000000000000000000000..1956a2bae35b1da988bc83fcc6a86df055fef93b --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts @@ -0,0 +1,75 @@ +import { IInstantiationService } from "#/_base/di/instantiation"; +import { Service } from "#/_base/di/service"; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; +import { AutoModeApprovePermissionPolicyService } from '#/agent/permissionPolicy/policies/auto-mode-approve'; +import { AutoModeAskUserQuestionDenyPermissionPolicyService } from '#/agent/permissionPolicy/policies/auto-mode-ask-user-question-deny'; +import { DangerousCommandAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/dangerous-command-ask'; +import { DefaultToolApprovePermissionPolicyService } from '#/agent/permissionPolicy/policies/default-tool-approve'; +import { FallbackAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/fallback-ask'; +import { GitControlPathAccessAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/git-control-path-access-ask'; +import { GitCwdWriteApprovePermissionPolicyService } from '#/agent/permissionPolicy/policies/git-cwd-write-approve'; +import { SensitiveFileAccessAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/sensitive-file-access-ask'; +import { SessionApprovalHistoryPermissionPolicyService } from '#/agent/permissionPolicy/policies/session-approval-history'; +import { UserConfiguredAllowPermissionPolicyService } from '#/agent/permissionPolicy/policies/user-configured-allow'; +import { UserConfiguredAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/user-configured-ask'; +import { UserConfiguredDenyPermissionPolicyService } from '#/agent/permissionPolicy/policies/user-configured-deny'; +import { YoloModeApprovePermissionPolicyService } from '#/agent/permissionPolicy/policies/yolo-mode-approve'; +import { + IAgentPermissionPolicyService, + type PermissionPolicyEvaluation, +} from './permissionPolicy'; +import type { PermissionPolicy } from "./types"; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; + +export class AgentPermissionPolicyService + extends Service + implements IAgentPermissionPolicyService +{ + declare readonly _serviceBrand: undefined; + + private readonly policies: readonly PermissionPolicy[]; + + constructor( + @IInstantiationService private readonly instantiation: IInstantiationService, + @IBootstrapService bootstrap: IBootstrapService, + ) { + super(); + this.policies = [ + this.instantiation.createInstance(AutoModeAskUserQuestionDenyPermissionPolicyService), + this.instantiation.createInstance(UserConfiguredDenyPermissionPolicyService), + ...(bootstrap.args.nonInteractive + ? [] + : [this.instantiation.createInstance(DangerousCommandAskPermissionPolicyService)]), + this.instantiation.createInstance(AutoModeApprovePermissionPolicyService), + this.instantiation.createInstance(SessionApprovalHistoryPermissionPolicyService), + this.instantiation.createInstance(UserConfiguredAskPermissionPolicyService), + this.instantiation.createInstance(UserConfiguredAllowPermissionPolicyService), + this.instantiation.createInstance(SensitiveFileAccessAskPermissionPolicyService), + this.instantiation.createInstance(GitControlPathAccessAskPermissionPolicyService), + this.instantiation.createInstance(YoloModeApprovePermissionPolicyService), + this.instantiation.createInstance(DefaultToolApprovePermissionPolicyService), + this.instantiation.createInstance(GitCwdWriteApprovePermissionPolicyService), + this.instantiation.createInstance(FallbackAskPermissionPolicyService), + ]; + } + + async evaluate( + context: ResolvedToolExecutionHookContext, + ): Promise { + for (const policy of this.policies) { + const result = await policy.evaluate(context); + if (result !== undefined) return { policyName: policy.name, result }; + } + return undefined; + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentPermissionPolicyService, + AgentPermissionPolicyService, + ScopeActivation.OnScopeCreated, + 'permissionPolicy', +); diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/auto-mode-approve.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/auto-mode-approve.ts new file mode 100644 index 0000000000000000000000000000000000000000..5fe0b14bf39aaf5e4c5d80f530454a2f556e7cf0 --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/auto-mode-approve.ts @@ -0,0 +1,17 @@ +import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; +import type { + PermissionPolicy, + PermissionPolicyResult, +} from '#/agent/permissionPolicy/types'; + +export class AutoModeApprovePermissionPolicyService implements PermissionPolicy { + readonly name = 'auto-mode-approve'; + + constructor( + @IAgentPermissionModeService private readonly modeService: IAgentPermissionModeService, + ) {} + + evaluate(): PermissionPolicyResult | undefined { + return this.modeService.mode === 'auto' ? { kind: 'approve' } : undefined; + } +} diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/auto-mode-ask-user-question-deny.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/auto-mode-ask-user-question-deny.ts new file mode 100644 index 0000000000000000000000000000000000000000..a9e36020da0930628a97063da7a26a495ebf518a --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/auto-mode-ask-user-question-deny.ts @@ -0,0 +1,24 @@ +import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; +import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; +import type { + PermissionPolicy, + PermissionPolicyResult, +} from '#/agent/permissionPolicy/types'; + +export class AutoModeAskUserQuestionDenyPermissionPolicyService implements PermissionPolicy { + readonly name = 'auto-mode-ask-user-question-deny'; + + constructor( + @IAgentPermissionModeService private readonly modeService: IAgentPermissionModeService, + ) {} + + evaluate(context: ResolvedToolExecutionHookContext): PermissionPolicyResult | undefined { + if (this.modeService.mode !== 'auto') return undefined; + if (context.toolCall.name !== 'AskUserQuestion') return undefined; + return { + kind: 'deny', + message: + 'AskUserQuestion is disabled while auto permission mode is active. Make a reasonable decision and continue without asking the user.', + }; + } +} diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/dangerous-command-ask.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/dangerous-command-ask.ts new file mode 100644 index 0000000000000000000000000000000000000000..20dbfc2fdbfb3b94fce5719a5caf39a1522dea99 --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/dangerous-command-ask.ts @@ -0,0 +1,376 @@ +import { + IBashParserService, + type BashParseResult, + type BashSyntaxNode, +} from '#/app/bashParser/bashParser'; +import { IConfigService } from '#/app/config/config'; +import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; +import { isDangerousCommandGuardEnabled } from '#/agent/permissionRules/configSection'; +import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; +import type { + PermissionPolicy, + PermissionPolicyResult, +} from '#/agent/permissionPolicy/types'; + +const PARSE_OPTIONS = { timeoutMs: 500, maxNodes: 10_000 } as const; + +const MAX_NESTED_SHELL_DEPTH = 4; + +const UNSAFE_OPERAND = /[$`*?[\]~]/; + +const SKIPPED_COMMAND_CHILDREN: ReadonlySet = new Set([ + 'variable_assignment', + 'file_redirect', + 'heredoc_redirect', +]); + +const SIMPLE_DANGEROUS_COMMANDS: ReadonlySet = new Set([ + 'shutdown', + 'halt', + 'poweroff', + 'reboot', + 'bcdedit', + 'diskpart', + 'format', + 'restart-computer', + 'stop-computer', + 'mkfs', + 'wipefs', +]); + +const PRIVILEGE_WRAPPERS: ReadonlySet = new Set(['sudo', 'doas']); + +const PRIVILEGE_VALUE_OPTIONS: ReadonlySet = new Set([ + '-u', + '--user', + '-g', + '--group', + '-h', + '--host', + '-p', + '--prompt', + '-C', + '--close-from', + '-T', + '--command-timeout', + '-U', + '--other-user', + '-r', + '--role', + '-t', + '--type', +]); + +const NESTED_SHELLS: ReadonlySet = new Set(['sh', 'bash', 'dash', 'zsh', 'ksh', 'ash']); + +const LAUNCH_WRAPPERS: ReadonlySet = new Set([ + 'env', + 'command', + 'exec', + 'nohup', + 'builtin', + 'nice', +]); + +const WRAPPER_VALUE_OPTIONS: ReadonlySet = new Set([ + '-u', + '--unset', + '-C', + '--chdir', + '-S', + '--split-string', + '-a', + '-n', + '--adjustment', +]); + +const SYSTEMCTL_DANGEROUS_SUBCOMMANDS: ReadonlySet = new Set([ + 'poweroff', + 'reboot', + 'halt', + 'kexec', +]); + +const SYSTEMCTL_VALUE_OPTIONS: ReadonlySet = new Set(['-H', '--host', '-M', '--machine']); + +const DD_SAFE_DEVICE_TARGETS: ReadonlySet = new Set([ + '/dev/null', + '/dev/zero', + '/dev/full', + '/dev/random', + '/dev/urandom', + '/dev/stdin', + '/dev/stdout', + '/dev/stderr', +]); + +const RM_SAFE_TEMP_ROOTS: readonly string[] = ['/tmp', '/temp']; + +function isSafeTempRmOperand(operand: string): boolean { + for (const segment of operand.split('/')) { + if (segment === '..') return false; + } + return RM_SAFE_TEMP_ROOTS.some((root) => operand === root || operand.startsWith(`${root}/`)); +} + +type DangerousVerdict = + | { readonly kind: 'dangerous'; readonly command: string } + | { readonly kind: 'unanalyzable' }; + +export class DangerousCommandAskPermissionPolicyService implements PermissionPolicy { + readonly name = 'dangerous-command-ask'; + + constructor( + @IBashParserService private readonly bashParser: IBashParserService, + @IAgentPermissionModeService private readonly modeService: IAgentPermissionModeService, + @IConfigService private readonly config: IConfigService, + ) {} + + evaluate(context: ResolvedToolExecutionHookContext): PermissionPolicyResult | undefined { + if (!isDangerousCommandGuardEnabled(this.config)) return undefined; + if (this.modeService.mode === 'auto') return undefined; + if (context.toolCall.name !== 'Bash') return undefined; + const command = bashCommandText(context.args); + const verdict = + command === undefined + ? ({ kind: 'unanalyzable' } as const) + : analyzeSource(command, 0, (source) => + this.bashParser.parse(source, PARSE_OPTIONS), + ); + if (verdict === undefined) return undefined; + if (verdict.kind === 'dangerous') { + return { kind: 'ask', reason: { dangerous_command: verdict.command } }; + } + return { kind: 'ask', reason: { unanalyzable_command: true } }; + } +} + +function bashCommandText(args: unknown): string | undefined { + if (typeof args !== 'object' || args === null) return undefined; + const command = (args as { readonly command?: unknown }).command; + return typeof command === 'string' ? command : undefined; +} + +function analyzeSource( + source: string, + depth: number, + parse: (source: string) => BashParseResult, +): DangerousVerdict | undefined { + const parsed = parse(source); + if (!parsed.ok || parsed.hasError) return { kind: 'unanalyzable' }; + const commands: BashSyntaxNode[] = []; + collectCommands(parsed.root, commands); + for (const command of commands) { + const verdict = analyzeCommand(command, depth, parse); + if (verdict !== undefined) return verdict; + } + return undefined; +} + +function collectCommands(node: BashSyntaxNode, out: BashSyntaxNode[]): void { + if (node.type === 'command') out.push(node); + for (const child of node.children) collectCommands(child, out); +} + +function analyzeCommand( + command: BashSyntaxNode, + depth: number, + parse: (source: string) => BashParseResult, +): DangerousVerdict | undefined { + const nameIndex = command.children.findIndex((child) => child.type === 'command_name'); + const nameNode = nameIndex >= 0 ? command.children[nameIndex] : undefined; + const nameWord = nameNode?.children.find((child) => child.isNamed); + const rawName = nameWord === undefined ? undefined : literalText(nameWord); + if (rawName === undefined || rawName.length === 0) return { kind: 'unanalyzable' }; + const args: string[] = []; + let dropped = false; + for (const child of command.children.slice(nameIndex + 1)) { + if (SKIPPED_COMMAND_CHILDREN.has(child.type)) continue; + const value = literalText(child); + if (value === undefined) { + dropped = true; + } else if (value.length > 0) { + args.push(value); + } + } + return analyzeInvocation(normalizeCommandName(rawName), args, dropped, depth, parse); +} + +function analyzeInvocation( + name: string, + args: readonly string[], + dropped: boolean, + depth: number, + parse: (source: string) => BashParseResult, +): DangerousVerdict | undefined { + if (PRIVILEGE_WRAPPERS.has(name)) { + const rest = dropLeadingOptions(args, PRIVILEGE_VALUE_OPTIONS); + const inner = rest[0]; + if (inner === undefined) return dropped ? { kind: 'unanalyzable' } : undefined; + return analyzeInvocation(normalizeCommandName(inner), rest.slice(1), dropped, depth, parse); + } + if (LAUNCH_WRAPPERS.has(name)) { + if (name === 'command') { + for (const arg of args) { + if (arg === '--') break; + if (arg === '-') continue; + if (!arg.startsWith('-')) break; + if (/[vV]/.test(arg)) return undefined; + } + } + const rest = dropLaunchWrapperOperands(name, args); + const inner = rest[0]; + if (inner === undefined) return dropped ? { kind: 'unanalyzable' } : undefined; + return analyzeInvocation(normalizeCommandName(inner), rest.slice(1), dropped, depth, parse); + } + if (NESTED_SHELLS.has(name)) { + let payloadIndex = -1; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]!; + if (arg === '--') break; + if (/^-[a-zA-Z]+$/.test(arg)) { + if (arg.includes('c')) payloadIndex = i + 1; + } else { + break; + } + } + if (payloadIndex < 0) return dropped ? { kind: 'unanalyzable' } : undefined; + const payload = args[payloadIndex]; + if (payload === undefined || depth >= MAX_NESTED_SHELL_DEPTH) { + return { kind: 'unanalyzable' }; + } + return analyzeSource(payload, depth + 1, parse); + } + if (name === 'eval') { + if (args.length === 0) return dropped ? { kind: 'unanalyzable' } : undefined; + if (dropped || depth >= MAX_NESTED_SHELL_DEPTH) return { kind: 'unanalyzable' }; + return analyzeSource(args.join(' '), depth + 1, parse); + } + if (name === 'busybox') { + const applet = args[0]; + if (applet === undefined || applet.startsWith('-')) { + return dropped ? { kind: 'unanalyzable' } : undefined; + } + return analyzeInvocation(normalizeCommandName(applet), args.slice(1), dropped, depth, parse); + } + if (SIMPLE_DANGEROUS_COMMANDS.has(name) || name.startsWith('mkfs.')) { + return { kind: 'dangerous', command: name }; + } + if (name === 'init' || name === 'telinit') { + if (args.some((arg) => arg === '0' || arg === '6')) { + return { kind: 'dangerous', command: name }; + } + return dropped ? { kind: 'unanalyzable' } : undefined; + } + if (name === 'systemctl') { + const subcommand = dropLeadingOptions(args, SYSTEMCTL_VALUE_OPTIONS)[0]; + if (subcommand !== undefined && SYSTEMCTL_DANGEROUS_SUBCOMMANDS.has(subcommand)) { + return { kind: 'dangerous', command: `systemctl ${subcommand}` }; + } + return dropped ? { kind: 'unanalyzable' } : undefined; + } + if (name === 'dd') { + for (const arg of args) { + if (!arg.startsWith('of=')) continue; + const target = arg.slice('of='.length); + if (target.startsWith('/dev/') && !DD_SAFE_DEVICE_TARGETS.has(target)) { + return { kind: 'dangerous', command: 'dd' }; + } + } + return dropped ? { kind: 'unanalyzable' } : undefined; + } + if (name === 'rm') { + let recursive = false; + let force = false; + const operands: string[] = []; + let optionsEnded = false; + for (const arg of args) { + if (!optionsEnded && arg === '--') { + optionsEnded = true; + continue; + } + if (optionsEnded) { + operands.push(arg); + continue; + } + if (arg === '--recursive') { + recursive = true; + } else if (arg === '--force') { + force = true; + } else if (/^-[a-zA-Z]+$/.test(arg)) { + if (/[rR]/.test(arg)) recursive = true; + if (arg.includes('f')) force = true; + } else { + operands.push(arg); + } + } + if (recursive && force) { + if (!dropped && operands.length > 0 && operands.every(isSafeTempRmOperand)) { + return undefined; + } + return { kind: 'dangerous', command: 'rm -rf' }; + } + return dropped ? { kind: 'unanalyzable' } : undefined; + } + return undefined; +} + +function normalizeCommandName(raw: string): string { + let name = raw; + const separator = Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')); + if (separator >= 0) name = name.slice(separator + 1); + name = name.toLowerCase(); + if (name.endsWith('.exe')) name = name.slice(0, -'.exe'.length); + return name; +} + +function dropLeadingOptions(args: readonly string[], valueOptions: ReadonlySet): string[] { + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]!; + if (arg === '--') return args.slice(i + 1); + if (arg === '-' || !arg.startsWith('-')) return args.slice(i); + if (!arg.includes('=') && valueOptions.has(arg)) i += 1; + } + return []; +} + +function dropLaunchWrapperOperands(name: string, args: readonly string[]): string[] { + let rest = dropLeadingOptions(args, WRAPPER_VALUE_OPTIONS); + if (name === 'env') { + let i = rest[0] === '-' ? 1 : 0; + while (i < rest.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(rest[i]!)) i += 1; + rest = rest.slice(i); + } + return rest; +} + +function literalText(node: BashSyntaxNode): string | undefined { + switch (node.type) { + case 'word': { + const raw = node.text; + if (UNSAFE_OPERAND.test(raw)) return undefined; + const unescaped = raw.replaceAll(/\\(.)/gs, '$1'); + return UNSAFE_OPERAND.test(unescaped) ? undefined : unescaped; + } + case 'number': + return node.text; + case 'raw_string': { + if (node.text.length < 2) return undefined; + const value = node.text.slice(1, -1); + return UNSAFE_OPERAND.test(value) ? undefined : value; + } + case 'string': { + let value = ''; + for (const child of node.children) { + if (child.type === 'string_content') { + value += child.text; + } else if (child.isNamed) { + return undefined; + } + } + return UNSAFE_OPERAND.test(value) ? undefined : value; + } + default: + return undefined; + } +} diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/default-tool-approve.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/default-tool-approve.ts new file mode 100644 index 0000000000000000000000000000000000000000..d826a7f4eb758d93e872614be76408fc8d00db70 --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/default-tool-approve.ts @@ -0,0 +1,42 @@ +import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; +import type { + PermissionPolicy, + PermissionPolicyResult, +} from '#/agent/permissionPolicy/types'; + +const DEFAULT_APPROVE_TOOLS = new Set([ + 'Read', + 'Grep', + 'Glob', + 'ReadMediaFile', + 'SetTodoList', + 'TodoList', + 'TaskList', + 'TaskOutput', + 'WaitFor', + 'CronList', + 'WebSearch', + 'FetchURL', + 'Agent', + 'AgentSwarm', + 'AskUserQuestion', + 'NotifyUser', + 'Skill', + 'EnterPlanMode', + 'ExitPlanMode', + 'CreateGoal', + 'GetGoal', + 'SetGoalBudget', + 'UpdateGoal', + 'select_tools', +]); + +export class DefaultToolApprovePermissionPolicyService implements PermissionPolicy { + readonly name = 'default-tool-approve'; + + evaluate(context: ResolvedToolExecutionHookContext): PermissionPolicyResult | undefined { + return DEFAULT_APPROVE_TOOLS.has(context.toolCall.name) + ? { kind: 'approve' } + : undefined; + } +} diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/fallback-ask.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/fallback-ask.ts new file mode 100644 index 0000000000000000000000000000000000000000..9cf780380e921fb525134b7660077743918fdc1e --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/fallback-ask.ts @@ -0,0 +1,12 @@ +import type { + PermissionPolicy, + PermissionPolicyResult, +} from '#/agent/permissionPolicy/types'; + +export class FallbackAskPermissionPolicyService implements PermissionPolicy { + readonly name = 'fallback-ask'; + + evaluate(): PermissionPolicyResult { + return { kind: 'ask' }; + } +} diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-control-path-access-ask.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-control-path-access-ask.ts new file mode 100644 index 0000000000000000000000000000000000000000..c9df3bf7efb1e63e07afcff6f7ba15e2eb0f06c7 --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-control-path-access-ask.ts @@ -0,0 +1,49 @@ +import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; +import { IGitService } from '#/app/git/git'; +import type { IGitService as GitService } from '#/app/git/git'; +import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import type { ISessionWorkspaceContext as WorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import type { + PermissionPolicy, + PermissionPolicyResult, +} from '#/agent/permissionPolicy/types'; +import { + fileAccesses, + hasGitPathComponent, + isGitControlPath, +} from './path-utils'; + +export class GitControlPathAccessAskPermissionPolicyService implements PermissionPolicy { + readonly name = 'git-control-path-access-ask'; + + constructor( + @IAgentRuntimeService private readonly runtime: IAgentRuntimeService, + @ISessionWorkspaceContext private readonly workspace: WorkspaceContext, + @IGitService private readonly git: GitService, + ) {} + + async evaluate( + context: ResolvedToolExecutionHookContext, + ): Promise { + const cwd = this.workspace.workDir; + if (cwd.length === 0) return undefined; + const lease = this.runtime.acquire(); + const pathClass = lease.runtime.environment.pathClass; + lease.dispose(); + const accesses = fileAccesses(context); + if (accesses.length === 0) return undefined; + + const directGitAccess = accesses.find((fileAccess) => + hasGitPathComponent(fileAccess.path, cwd, pathClass), + ); + if (directGitAccess !== undefined) return { kind: 'ask' }; + + const marker = await this.git.findWorkTree(cwd); + if (marker === null) return undefined; + const access = accesses.find((fileAccess) => + isGitControlPath(fileAccess.path, marker, pathClass), + ); + return access === undefined ? undefined : { kind: 'ask' }; + } +} diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts new file mode 100644 index 0000000000000000000000000000000000000000..b48d71cf7034ef5d478ebfe0f76805209cc3387a --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts @@ -0,0 +1,54 @@ +import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; +import { isWithinWorkspace } from '#/tool/path-access'; +import { IGitService } from '#/app/git/git'; +import type { IGitService as GitService } from '#/app/git/git'; +import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import type { ISessionWorkspaceContext as WorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import type { + PermissionPolicy, + PermissionPolicyResult, +} from '#/agent/permissionPolicy/types'; +import { writeFileAccesses } from './path-utils'; + +export class GitCwdWriteApprovePermissionPolicyService implements PermissionPolicy { + readonly name = 'git-cwd-write-approve'; + + constructor( + @IAgentRuntimeService private readonly runtime: IAgentRuntimeService, + @ISessionWorkspaceContext private readonly workspace: WorkspaceContext, + @IGitService private readonly git: GitService, + ) {} + + async evaluate( + context: ResolvedToolExecutionHookContext, + ): Promise { + const toolName = context.toolCall.name; + if (toolName !== 'Write' && toolName !== 'Edit') return undefined; + const lease = this.runtime.acquire(); + const pathClass = lease.runtime.environment.pathClass; + lease.dispose(); + if (pathClass !== 'posix') return undefined; + + const cwd = this.workspace.workDir; + if (cwd.length === 0) return undefined; + + const writeAccesses = writeFileAccesses(context); + if (writeAccesses.length === 0) return undefined; + if ( + !writeAccesses.every((access) => + isWithinWorkspace( + access.path, + { workspaceDir: cwd, additionalDirs: this.workspace.additionalDirs }, + 'posix', + ), + ) + ) { + return undefined; + } + + return (await this.git.findWorkTree(cwd)) === null + ? undefined + : { kind: 'approve' }; + } +} diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/path-utils.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/path-utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..b64439e546116dc29c04179fcffeaab87d42562e --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/path-utils.ts @@ -0,0 +1,59 @@ +import * as posixPath from 'node:path/posix'; +import * as win32Path from 'node:path/win32'; + +import type { GitWorkTree } from '#/app/git/workTree'; +import type { ToolFileAccess } from '#/tool/toolContract'; +import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; +import { + isWithinDirectory, + type PathClass, +} from '#/tool/path-access'; + +export function fileAccesses(context: ResolvedToolExecutionHookContext): ToolFileAccess[] { + return ( + context.execution.accesses?.filter((access): access is ToolFileAccess => access.kind === 'file') ?? + [] + ); +} + +export function writeFileAccesses(context: ResolvedToolExecutionHookContext): ToolFileAccess[] { + return fileAccesses(context).filter( + (access) => access.operation === 'write' || access.operation === 'readwrite', + ); +} + +export function hasGitPathComponent( + targetPath: string, + cwd: string, + pathClass: PathClass, +): boolean { + return relativePathParts(targetPath, cwd, pathClass).some( + (part) => part.toLowerCase() === '.git', + ); +} + +export function isGitControlPath( + targetPath: string, + marker: GitWorkTree, + pathClass: PathClass, +): boolean { + return ( + isWithinDirectory(targetPath, marker.dotGitPath, pathClass) || + isWithinDirectory(targetPath, marker.controlDirPath, pathClass) + ); +} + +export function defaultPathClass(): PathClass { + return process.platform === 'win32' ? 'win32' : 'posix'; +} + +function relativePathParts(targetPath: string, cwd: string, pathClass: PathClass): string[] { + return pathMod(pathClass) + .relative(cwd, targetPath) + .split(/[\\/]+/) + .filter((part) => part.length > 0); +} + +function pathMod(pathClass: PathClass): typeof posixPath { + return pathClass === 'win32' ? win32Path : posixPath; +} diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/sensitive-file-access-ask.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/sensitive-file-access-ask.ts new file mode 100644 index 0000000000000000000000000000000000000000..8a2d73720256e311686db2cb256b9882574f3e6e --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/sensitive-file-access-ask.ts @@ -0,0 +1,16 @@ +import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; +import { isSensitiveFile } from '#/tool/path-access'; +import type { + PermissionPolicy, + PermissionPolicyResult, +} from '#/agent/permissionPolicy/types'; +import { fileAccesses } from './path-utils'; + +export class SensitiveFileAccessAskPermissionPolicyService implements PermissionPolicy { + readonly name = 'sensitive-file-access-ask'; + + evaluate(context: ResolvedToolExecutionHookContext): PermissionPolicyResult | undefined { + const access = fileAccesses(context).find((fileAccess) => isSensitiveFile(fileAccess.path)); + return access === undefined ? undefined : { kind: 'ask' }; + } +} diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/session-approval-history.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/session-approval-history.ts new file mode 100644 index 0000000000000000000000000000000000000000..c299c3895d7b1ed02207642adca1c46a41c34fb3 --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/session-approval-history.ts @@ -0,0 +1,40 @@ +import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; +import { matchPermissionRule } from '#/agent/permissionRules/matchesRule'; +import { IAgentPermissionRulesService } from '#/agent/permissionRules/permissionRules'; +import type { + PermissionPolicy, + PermissionPolicyResult, +} from '#/agent/permissionPolicy/types'; + +export class SessionApprovalHistoryPermissionPolicyService implements PermissionPolicy { + readonly name = 'session-approval-history'; + + constructor( + @IAgentPermissionRulesService private readonly rulesService: IAgentPermissionRulesService, + ) {} + + evaluate(context: ResolvedToolExecutionHookContext): PermissionPolicyResult | undefined { + for (const pattern of this.rulesService.sessionApprovalRulePatterns) { + const match = matchPermissionRule({ + rule: { + decision: 'allow', + scope: 'session-runtime', + pattern, + reason: 'approve for session', + }, + toolName: context.toolCall.name, + execution: context.execution, + }); + if (match !== undefined) { + return { + kind: 'approve', + reason: { + has_rule_args: match.hasRuleArgs, + match_strategy: match.strategy, + }, + }; + } + } + return undefined; + } +} diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/user-configured-allow.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/user-configured-allow.ts new file mode 100644 index 0000000000000000000000000000000000000000..11f5fe88178db14a8b3523a246be1a8222d9a9eb --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/user-configured-allow.ts @@ -0,0 +1,17 @@ +import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; +import { IAgentPermissionRulesService } from '#/agent/permissionRules/permissionRules'; +import type { + PermissionPolicy, + PermissionPolicyResult, +} from '#/agent/permissionPolicy/types'; +import { evaluateUserConfiguredRule } from './user-configured-rule'; + +export class UserConfiguredAllowPermissionPolicyService implements PermissionPolicy { + readonly name = 'user-configured-allow'; + + constructor(@IAgentPermissionRulesService private readonly rulesService: IAgentPermissionRulesService) {} + + evaluate(context: ResolvedToolExecutionHookContext): PermissionPolicyResult | undefined { + return evaluateUserConfiguredRule(context, 'allow', this.rulesService); + } +} diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/user-configured-ask.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/user-configured-ask.ts new file mode 100644 index 0000000000000000000000000000000000000000..50abcf9683e9f5b09cd06493a94260351fd455bf --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/user-configured-ask.ts @@ -0,0 +1,17 @@ +import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; +import { IAgentPermissionRulesService } from '#/agent/permissionRules/permissionRules'; +import type { + PermissionPolicy, + PermissionPolicyResult, +} from '#/agent/permissionPolicy/types'; +import { evaluateUserConfiguredRule } from './user-configured-rule'; + +export class UserConfiguredAskPermissionPolicyService implements PermissionPolicy { + readonly name = 'user-configured-ask'; + + constructor(@IAgentPermissionRulesService private readonly rulesService: IAgentPermissionRulesService) {} + + evaluate(context: ResolvedToolExecutionHookContext): PermissionPolicyResult | undefined { + return evaluateUserConfiguredRule(context, 'ask', this.rulesService); + } +} diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/user-configured-deny.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/user-configured-deny.ts new file mode 100644 index 0000000000000000000000000000000000000000..490ea7f38bdc83e5586bb676e8c0634bdc3820ff --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/user-configured-deny.ts @@ -0,0 +1,17 @@ +import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; +import { IAgentPermissionRulesService } from '#/agent/permissionRules/permissionRules'; +import type { + PermissionPolicy, + PermissionPolicyResult, +} from '#/agent/permissionPolicy/types'; +import { evaluateUserConfiguredRule } from './user-configured-rule'; + +export class UserConfiguredDenyPermissionPolicyService implements PermissionPolicy { + readonly name = 'user-configured-deny'; + + constructor(@IAgentPermissionRulesService private readonly rulesService: IAgentPermissionRulesService) {} + + evaluate(context: ResolvedToolExecutionHookContext): PermissionPolicyResult | undefined { + return evaluateUserConfiguredRule(context, 'deny', this.rulesService); + } +} diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/user-configured-rule.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/user-configured-rule.ts new file mode 100644 index 0000000000000000000000000000000000000000..887471cc39045b261970dd5fc88cdafc3d4da549 --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/user-configured-rule.ts @@ -0,0 +1,61 @@ +import type { + PermissionRule, + PermissionRuleDecision, + PermissionRuleScope, +} from '#/agent/permissionRules/permissionRules'; +import { + matchPermissionRule, + type PermissionRuleMatch, +} from '#/agent/permissionRules/matchesRule'; +import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; +import type { IAgentPermissionRulesService } from '#/agent/permissionRules/permissionRules'; +import type { PermissionPolicyResult } from '#/agent/permissionPolicy/types'; + +const USER_CONFIGURED_SCOPES = new Set([ + 'turn-override', + 'project', + 'user', +]); + +export function evaluateUserConfiguredRule( + context: ResolvedToolExecutionHookContext, + decision: PermissionRuleDecision, + rulesService: IAgentPermissionRulesService, +): PermissionPolicyResult | undefined { + const match = firstMatchingRule(context, decision, rulesService, USER_CONFIGURED_SCOPES); + if (match === undefined) return undefined; + if (decision === 'deny') { + return { + kind: 'deny', + message: defaultPermissionRuleDenyMessage(context.toolCall.name, match.rule.reason), + }; + } + if (decision === 'ask') return { kind: 'ask' }; + return { kind: 'approve' }; +} + +function defaultPermissionRuleDenyMessage(tool: string, reason: string | undefined): string { + const suffix = reason !== undefined && reason.length > 0 ? ` Reason: ${reason}` : ''; + return `Tool "${tool}" was denied by permission rule.${suffix}`; +} + +function firstMatchingRule( + context: ResolvedToolExecutionHookContext, + decision: PermissionRuleDecision, + rulesService: IAgentPermissionRulesService, + scopes: ReadonlySet, +): PermissionRuleMatch | undefined { + const rules = rulesService.rules.filter((rule): rule is PermissionRule => + scopes.has(rule.scope), + ); + for (const rule of rules) { + if (rule.decision !== decision) continue; + const match = matchPermissionRule({ + rule, + toolName: context.toolCall.name, + execution: context.execution, + }); + if (match !== undefined) return match; + } + return undefined; +} diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/yolo-mode-approve.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/yolo-mode-approve.ts new file mode 100644 index 0000000000000000000000000000000000000000..02988fa37a7ced7a1dae4494cb01fa9776c2e274 --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/yolo-mode-approve.ts @@ -0,0 +1,17 @@ +import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; +import type { + PermissionPolicy, + PermissionPolicyResult, +} from '#/agent/permissionPolicy/types'; + +export class YoloModeApprovePermissionPolicyService implements PermissionPolicy { + readonly name = 'yolo-mode-approve'; + + constructor( + @IAgentPermissionModeService private readonly modeService: IAgentPermissionModeService, + ) {} + + evaluate(): PermissionPolicyResult | undefined { + return this.modeService.mode === 'yolo' ? { kind: 'approve' } : undefined; + } +} diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/types.ts b/packages/agent-core-v2/src/agent/permissionPolicy/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..57cdec848afe42addd768d42128f661e637bc7fc --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionPolicy/types.ts @@ -0,0 +1,66 @@ +import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; +import type { ExecutableToolResult } from '#/tool/toolContract'; +import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; +import type { PermissionRule } from '#/agent/permissionRules/permissionRules'; + +export type PermissionMode = 'manual' | 'yolo' | 'auto'; + + +export interface ApprovalRequest { + id?: string; + toolCallId: string; + toolName: string; + action: string; + display: ToolInputDisplay; +} + +export interface ApprovalResponse { + decision: 'approved' | 'rejected' | 'cancelled'; + scope?: 'session'; + feedback?: string; + selectedLabel?: string; +} + +export interface PermissionData { + mode: PermissionMode; + rules: PermissionRule[]; +} + +export type PermissionDecision = 'approve' | 'deny' | 'ask'; + +export type PermissionReasonValue = string | number | boolean | null; + +export type PermissionDecisionReason = Readonly>; + +export type PermissionPolicyResolution = + | PermissionPolicyResult + | { readonly kind: 'result'; readonly result: ExecutableToolResult }; + +export interface PermissionPolicyContext extends ResolvedToolExecutionHookContext {} + +export type PermissionPolicyResult = + | { + readonly kind: 'approve'; + readonly reason?: PermissionDecisionReason; + readonly executionMetadata?: unknown; + } + | { + readonly kind: 'deny'; + readonly reason?: PermissionDecisionReason; + readonly message?: string; + } + | { + readonly kind: 'ask'; + readonly reason?: PermissionDecisionReason; + readonly resolveApproval?: ( + result: ApprovalResponse, + ) => PermissionPolicyResolution | undefined; + readonly resolveError?: (error: unknown) => PermissionPolicyResolution | undefined; + }; + +export interface PermissionPolicy { + readonly name: string; + evaluate( + context: PermissionPolicyContext, + ): PermissionPolicyResult | undefined | Promise; +} diff --git a/packages/agent-core-v2/src/agent/permissionRules/configSection.ts b/packages/agent-core-v2/src/agent/permissionRules/configSection.ts new file mode 100644 index 0000000000000000000000000000000000000000..f37a031106877ea6d518b84f7687a7dc245e25a6 --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionRules/configSection.ts @@ -0,0 +1,150 @@ +import { z } from 'zod'; + +import { + type EnvBindings, + envBindings, + stripEnvBoundFields, + type IConfigService, +} from '#/app/config/config'; +import { registerConfigSection } from '#/app/config/configSectionContributions'; +import { + cloneRecord, + isPlainObject, + plainObjectToToml, + transformPlainObject, +} from '#/app/config/toml'; + +import { parsePermissionPattern } from './matchesRule'; + +export const PERMISSION_SECTION = 'permission'; + +export const PermissionRuleDecisionSchema = z.enum(['allow', 'deny', 'ask']); +export const PermissionRuleScopeSchema = z.enum([ + 'turn-override', + 'session-runtime', + 'project', + 'user', +]); + +export const PermissionRuleSchema = z.object({ + decision: PermissionRuleDecisionSchema, + scope: PermissionRuleScopeSchema.default('user'), + pattern: z.string().min(1).refine(isValidPermissionPattern, { + message: 'Invalid permission rule pattern', + }), + reason: z.string().optional(), +}); + +export const PermissionConfigSchema = z.object({ + rules: z.array(PermissionRuleSchema).optional(), + dangerousCommandGuard: z.boolean().optional(), +}); + +export type PermissionConfig = z.infer; + +export const DANGEROUS_COMMAND_GUARD_ENV = 'KIMI_CODE_DANGEROUS_COMMAND_GUARD'; + +function parseDangerousCommandGuardEnv(raw: string): boolean | undefined { + if (raw === 'true') return true; + if (raw === 'false') return false; + return undefined; +} + +export const permissionEnvBindings: EnvBindings = envBindings( + PermissionConfigSchema, + { + dangerousCommandGuard: { + env: DANGEROUS_COMMAND_GUARD_ENV, + parse: parseDangerousCommandGuardEnv, + }, + }, +); + +export const stripPermissionEnv = stripEnvBoundFields(permissionEnvBindings); + +export function isDangerousCommandGuardEnabled(config: IConfigService): boolean { + return ( + config.get(PERMISSION_SECTION)?.dangerousCommandGuard ?? true + ); +} + +function isValidPermissionPattern(pattern: string): boolean { + try { + parsePermissionPattern(pattern); + return true; + } catch { + return false; + } +} + +export const permissionFromToml = (rawSnake: unknown): unknown => { + if (!isPlainObject(rawSnake)) return rawSnake; + const raw = transformPlainObject(rawSnake); + const rules: unknown[] = []; + appendPermissionRules(rules, raw['rules']); + appendPermissionRules(rules, raw['deny'], 'deny'); + appendPermissionRules(rules, raw['allow'], 'allow'); + appendPermissionRules(rules, raw['ask'], 'ask'); + const out: Record = {}; + if (rules.length > 0) out['rules'] = rules; + if (raw['dangerousCommandGuard'] !== undefined) { + out['dangerousCommandGuard'] = raw['dangerousCommandGuard']; + } + return out; +}; + +function appendPermissionRules( + target: unknown[], + value: unknown, + decision?: 'allow' | 'deny' | 'ask', +): void { + if (value === undefined) return; + const entries = Array.isArray(value) ? value : [value]; + for (const entry of entries) { + target.push(transformPermissionRule(entry, decision)); + } +} + +function transformPermissionRule(value: unknown, decision?: 'allow' | 'deny' | 'ask'): unknown { + if (!isPlainObject(value)) return value; + const rule = transformPlainObject(value); + const tool = rule['tool']; + const match = rule['match']; + const pattern = rule['pattern']; + const out: Record = { + decision: decision !== undefined ? decision : rule['decision'], + scope: rule['scope'], + reason: rule['reason'], + }; + if (typeof tool === 'string') { + const argPattern = typeof match === 'string' ? match : pattern; + out['pattern'] = typeof argPattern === 'string' ? `${tool}(${argPattern})` : tool; + } else { + out['pattern'] = pattern; + } + return out; +} + +export const permissionToToml = (value: unknown, rawSnake: unknown): unknown => { + if (!isPlainObject(value)) return value; + const out = cloneRecord(rawSnake); + delete out['deny']; + delete out['allow']; + delete out['ask']; + const rules = value['rules']; + if (Array.isArray(rules)) { + out['rules'] = rules.map((rule) => + isPlainObject(rule) ? plainObjectToToml(rule, undefined) : rule, + ); + } else { + delete out['rules']; + } + return out; +}; + +registerConfigSection(PERMISSION_SECTION, PermissionConfigSchema, { + fromToml: permissionFromToml, + toToml: permissionToToml, + env: permissionEnvBindings, + stripEnv: stripPermissionEnv, +}); diff --git a/packages/agent-core-v2/src/agent/permissionRules/matchesRule.ts b/packages/agent-core-v2/src/agent/permissionRules/matchesRule.ts new file mode 100644 index 0000000000000000000000000000000000000000..d67ca9d409adc10463092e6c90b36bedfe6d2e9f --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionRules/matchesRule.ts @@ -0,0 +1,83 @@ +import picomatch from 'picomatch'; + +import { Error2, ErrorCodes } from '#/errors'; +import type { RunnableToolExecution } from '#/tool/toolContract'; +import type { PermissionRule } from './permissionRules'; + +export interface ParsedPattern { + readonly toolName: string; + readonly argPattern?: string; +} + +export type ParsedPermissionPattern = ParsedPattern; + +export interface PermissionRuleMatchExecution { + readonly matchesRule?: RunnableToolExecution['matchesRule']; +} + +export type PermissionRuleMatchStrategy = 'tool_name_only' | 'matches_rule'; + +export interface PermissionRuleMatch { + readonly rule: PermissionRule; + readonly strategy: PermissionRuleMatchStrategy; + readonly hasRuleArgs: boolean; +} + +export interface PermissionRuleMatchInput { + readonly rule: PermissionRule; + readonly toolName: string; + readonly execution: PermissionRuleMatchExecution; +} + +export function parsePattern(pattern: string): ParsedPattern { + const trimmed = pattern.trim(); + if (trimmed.length === 0) { + throw new Error2(ErrorCodes.VALIDATION_FAILED, 'permission pattern: empty string'); + } + + const openIdx = trimmed.indexOf('('); + if (openIdx === -1) { + return { toolName: trimmed }; + } + + if (!trimmed.endsWith(')')) { + throw new Error2(ErrorCodes.VALIDATION_FAILED, `permission pattern: missing closing paren in "${pattern}"`); + } + + const toolName = trimmed.slice(0, openIdx); + const argPattern = trimmed.slice(openIdx + 1, -1); + if (toolName.length === 0) { + throw new Error2(ErrorCodes.VALIDATION_FAILED, `permission pattern: empty tool name in "${pattern}"`); + } + if (argPattern.length === 0) { + return { toolName }; + } + return { toolName, argPattern }; +} + +export const parsePermissionPattern = parsePattern; + +export function matchPermissionRule({ + rule, + toolName, + execution, +}: PermissionRuleMatchInput): PermissionRuleMatch | undefined { + let parsed; + try { + parsed = parsePattern(rule.pattern); + } catch { + return undefined; + } + + if (parsed.toolName !== '*' && !picomatch.isMatch(toolName, parsed.toolName)) { + return undefined; + } + + if (parsed.argPattern === undefined) { + return { rule, strategy: 'tool_name_only', hasRuleArgs: false }; + } + + return execution.matchesRule?.(parsed.argPattern) === true + ? { rule, strategy: 'matches_rule', hasRuleArgs: true } + : undefined; +} diff --git a/packages/agent-core-v2/src/agent/permissionRules/permissionRules.ts b/packages/agent-core-v2/src/agent/permissionRules/permissionRules.ts new file mode 100644 index 0000000000000000000000000000000000000000..f524b5b416642bb9078b2445f5ddefc1ef88a6d6 --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionRules/permissionRules.ts @@ -0,0 +1,34 @@ +import { createDecorator } from "#/_base/di/instantiation"; +import type { ApprovalResponse } from "#/agent/interaction/approval"; + +export interface PermissionApprovalResultRecord { + readonly turnId: number; + readonly toolCallId: string; + readonly toolName: string; + readonly action: string; + readonly sessionApprovalRule?: string; + readonly result: ApprovalResponse; +} + +export type PermissionRuleDecision = 'allow' | 'deny' | 'ask'; + +export type PermissionRuleScope = 'turn-override' | 'session-runtime' | 'project' | 'user'; + +export interface PermissionRule { + readonly decision: PermissionRuleDecision; + readonly scope: PermissionRuleScope; + readonly pattern: string; + readonly reason?: string; +} + +export interface IAgentPermissionRulesService { + readonly _serviceBrand: undefined; + + readonly rules: readonly PermissionRule[]; + readonly sessionApprovalRulePatterns: readonly string[]; + addRules(rules: readonly PermissionRule[]): void; + recordApprovalResult(record: PermissionApprovalResultRecord): void; +} + +export const IAgentPermissionRulesService = + createDecorator('agentPermissionRulesService'); diff --git a/packages/agent-core-v2/src/agent/permissionRules/permissionRulesOps.ts b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesOps.ts new file mode 100644 index 0000000000000000000000000000000000000000..bf32582e78ea9ed56fbb3d8d05559c749512c4a1 --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesOps.ts @@ -0,0 +1,70 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { z } from 'zod'; + +import { AgentEvent2 } from '#/app/event/event2'; +import { defineState } from '#/state/state'; + +import type { PermissionApprovalResultRecord, PermissionRule } from './permissionRules'; + +export interface PermissionRulesModelState { + readonly rules: readonly PermissionRule[]; + readonly sessionApprovalRulePatterns: readonly string[]; +} + +const permissionRulesAddSchema = z.object({ + agentId: z.string(), + rules: z.custom(), +}); + +export class PermissionRulesAdd extends AgentEvent2> { + static override readonly type = 'permission.rules.add'; +} +export interface PermissionRulesAdd { + readonly agentId: string; + readonly rules: readonly PermissionRule[]; +} + +const permissionRecordApprovalResultSchema = z.object({ + agentId: z.string(), + turnId: z.number(), + toolCallId: z.string(), + toolName: z.string(), + action: z.string(), + sessionApprovalRule: z.string().optional(), + result: z.custom(), +}); + +export class PermissionRecordApprovalResult extends AgentEvent2< + z.infer +> { + static override readonly type = 'permission.record_approval_result'; + static override readonly durable = true; + static override readonly schema = permissionRecordApprovalResultSchema; +} +export interface PermissionRecordApprovalResult extends PermissionApprovalResultRecord { + readonly agentId: string; +} + +export const permissionRulesKey = defineState( + 'permissionRules', + (): PermissionRulesModelState => ({ + rules: [], + sessionApprovalRulePatterns: [], + }), +).replayable({ schema: z.custom() }) + .on(PermissionRulesAdd, (s, e) => { + if (e.rules.length === 0) return; + s.rules = [...s.rules, ...e.rules]; + }) + .on(PermissionRecordApprovalResult, (s, e) => { + const pattern = e.sessionApprovalRule; + if ( + e.result.decision !== 'approved' || + e.result.scope !== 'session' || + pattern === undefined || + s.sessionApprovalRulePatterns.includes(pattern) + ) { + return; + } + s.sessionApprovalRulePatterns = [...s.sessionApprovalRulePatterns, pattern]; + }); diff --git a/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts new file mode 100644 index 0000000000000000000000000000000000000000..046ed8c96749ae2ad077b74a2be5ad6188a42b69 --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts @@ -0,0 +1,61 @@ +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; + +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { IEventDispatcher } from '#/state/eventDispatcher'; +import { + IAgentPermissionRulesService, + type PermissionApprovalResultRecord, + type PermissionRule, +} from './permissionRules'; +import { + PermissionRecordApprovalResult, + PermissionRulesAdd, + permissionRulesKey, +} from './permissionRulesOps'; + +export class AgentPermissionRulesService implements IAgentPermissionRulesService { + declare readonly _serviceBrand: undefined; + + constructor( + @IEventDispatcher private readonly dispatcher: IEventDispatcher, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @IAgentStateService private readonly agentState: IAgentStateService, + ) { + this.agentState.contributeState(permissionRulesKey); + } + + get rules(): readonly PermissionRule[] { + return [...this.agentState.get(permissionRulesKey).rules]; + } + + get sessionApprovalRulePatterns(): readonly string[] { + return [...this.agentState.get(permissionRulesKey).sessionApprovalRulePatterns]; + } + + addRules(rules: readonly PermissionRule[]): void { + if (rules.length === 0) return; + void this.dispatcher.dispatch( + new PermissionRulesAdd({ agentId: this.scopeContext.agentId, rules: [...rules] }), + ); + } + + recordApprovalResult(record: PermissionApprovalResultRecord): void { + void this.dispatcher.dispatch( + new PermissionRecordApprovalResult({ + ...record, + agentId: this.scopeContext.agentId, + }), + ); + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentPermissionRulesService, + AgentPermissionRulesService, + ScopeActivation.OnScopeCreated, + 'permissionRules', +); diff --git a/packages/agent-core-v2/src/agent/plugin/agentPlugin.ts b/packages/agent-core-v2/src/agent/plugin/agentPlugin.ts new file mode 100644 index 0000000000000000000000000000000000000000..a5a64de2ae71e2128bc47b1264568e2eb33a1b8e --- /dev/null +++ b/packages/agent-core-v2/src/agent/plugin/agentPlugin.ts @@ -0,0 +1,10 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IAgentPluginService { + readonly _serviceBrand: undefined; + + refreshSessionStart(): Promise; +} + +export const IAgentPluginService: ServiceIdentifier = + createDecorator('agentPluginService'); diff --git a/packages/agent-core-v2/src/agent/plugin/agentPluginOps.ts b/packages/agent-core-v2/src/agent/plugin/agentPluginOps.ts new file mode 100644 index 0000000000000000000000000000000000000000..c689731b40d96f59bb9d391a5684b2708fc1b769 --- /dev/null +++ b/packages/agent-core-v2/src/agent/plugin/agentPluginOps.ts @@ -0,0 +1,37 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { z } from 'zod'; + +import { AgentEvent2 } from '#/app/event/event2'; +import { defineState } from '#/state/state'; + +export interface PluginSessionStartSnapshotState { + readonly initialized: boolean; + readonly content?: string; +} + +const pluginSessionStartSchema = z.object({ + agentId: z.string(), + content: z.string().nullable(), +}); + +export class PluginSessionStartEvent extends AgentEvent2< + z.infer +> { + static override readonly type = 'plugin.session_start'; + static override readonly durable = true; + static override readonly schema = pluginSessionStartSchema; +} +export interface PluginSessionStartEvent { + readonly agentId: string; + readonly content: string | null; +} + +export const pluginSessionStartSnapshotKey = defineState( + 'pluginSessionStartSnapshot', + (): PluginSessionStartSnapshotState => ({ initialized: false }), +) + .replayable({ schema: z.custom() }) + .on(PluginSessionStartEvent, (_s, e) => ({ + initialized: true, + content: e.content ?? undefined, + })); diff --git a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts new file mode 100644 index 0000000000000000000000000000000000000000..77905e66183ab11d3830e34a782a4cacd9a359af --- /dev/null +++ b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts @@ -0,0 +1,263 @@ +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { defineState } from '#/state/state'; +import { escapeXmlAttr } from '#/_base/utils/xml-escape'; +import { IAgentReminderService } from '#/features/reminder/reminderService'; +import type { ContextInjectionContext } from '#/features/reminder/types'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { systemReminderContent } from '#/features/reminder/systemReminder'; +import { IPluginService } from '#/app/plugin/plugin'; +import type { EnabledPluginSessionStart, PluginMutation } from '#/app/plugin/types'; +import { PLUGIN_SKILL_SOURCE_ID } from '#/features/skill/catalog/skillSource'; +import type { SkillCatalog, SkillDefinition } from '#/features/skill/catalog/types'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog'; +import { IEventDispatcher } from '#/state/eventDispatcher'; + +import { IAgentPluginService } from './agentPlugin'; +import { + PluginSessionStartEvent, + pluginSessionStartSnapshotKey, +} from './agentPluginOps'; + +const SESSION_START_INJECTION_VARIANT = 'plugin_session_start'; + +const PLUGIN_CHANGE_INJECTION_VARIANT = 'plugin_change'; + +const PLUGIN_CHANGE_VERBS: Record = { + install: 'installed', + enable: 'enabled', + disable: 'disabled', + remove: 'removed', + 'mcp-server': 'updated', +}; + +function renderPluginChangeReminder(mutation: PluginMutation): string { + return ( + `Plugin "${mutation.id}" was ${PLUGIN_CHANGE_VERBS[mutation.kind]}. ` + + 'This session keeps the prompt and tools it started with; ' + + 'run /new or /reload to apply the change, and tell the user if they expect it now.' + ); +} + +const MAIN_AGENT_ID = 'main'; + +const SUPERSEDES_SUFFIX = + 'This supersedes any earlier plugin_session_start reminder in this session.'; + +const NO_ACTIVE_SESSION_STARTS = + `There are currently no active plugin session starts. ${SUPERSEDES_SUFFIX}`; + +export const pluginSessionStartRefreshPendingKey = defineState( + 'agentPlugin.sessionStartRefreshPending', + () => false, +); + +export class AgentPluginService extends Service implements IAgentPluginService { + declare readonly _serviceBrand: undefined; + private readonly warnedMissingSessionStartSkills = new Set(); + + private pendingMutationCatalogChanges = 0; + + constructor( + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @IAgentReminderService private readonly reminder: IAgentReminderService, + @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + @IPluginService private readonly plugins: IPluginService, + @ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog, + @ISessionContext private readonly sessionContext: ISessionContext, + @ILogService private readonly log: ILogService, + @IAgentStateService private readonly states: IAgentStateService, + @IEventDispatcher private readonly dispatcher: IEventDispatcher, + ) { + super(); + this.states.contributeState(pluginSessionStartSnapshotKey); + if (scopeContext.agentId !== MAIN_AGENT_ID) return; + this.states.contributeState(pluginSessionStartRefreshPendingKey); + this._register( + this.reminder.register(SESSION_START_INJECTION_VARIANT, (injection) => + this.reconcileSessionStartReminder(injection), + ), + ); + this._register( + this.skillCatalog.onDidChange((sourceId) => { + if (sourceId !== PLUGIN_SKILL_SOURCE_ID) return; + if (this.pendingMutationCatalogChanges > 0) { + this.pendingMutationCatalogChanges--; + return; + } + this.refreshPending = true; + }), + ); + this._register( + this.plugins.onDidMutate(({ mutation }) => { + this.pendingMutationCatalogChanges++; + this.reminder.notify(renderPluginChangeReminder(mutation), { + variant: PLUGIN_CHANGE_INJECTION_VARIANT, + }); + }), + ); + } + + private get refreshPending(): boolean { + return this.states.get(pluginSessionStartRefreshPendingKey); + } + + private set refreshPending(value: boolean) { + this.states.set(pluginSessionStartRefreshPendingKey, value); + } + + async refreshSessionStart(): Promise { + if (this.scopeContext.agentId !== MAIN_AGENT_ID) return; + this.refreshPending = true; + await this.skillCatalog.ready; + await this.reminder.reconcileWhenIdle(SESSION_START_INJECTION_VARIANT); + } + + private async renderSessionStartReminder(): Promise { + const sessionStarts = await this.plugins.enabledSessionStarts(); + if (sessionStarts.length === 0) return undefined; + await this.skillCatalog.ready; + return renderPluginSessionStartReminder({ + sessionStarts, + catalog: this.skillCatalog.catalog, + log: this.log, + sessionId: this.sessionContext.sessionId, + warnedSkills: this.warnedMissingSessionStartSkills, + }); + } + + private async reconcileSessionStartReminder( + injection: ContextInjectionContext, + ): Promise { + const forceRefresh = this.refreshPending; + const desired = await this.resolveDesiredSessionStart(injection, forceRefresh); + this.refreshPending = false; + const latest = injection.lastInjection; + if (desired === undefined) { + if ( + latest === undefined && + (!forceRefresh || !shouldNeutralizePluginSessionStart(this.context.get())) + ) { + return undefined; + } + if (latest !== undefined && systemReminderContent(latest) === NO_ACTIVE_SESSION_STARTS) { + return undefined; + } + return NO_ACTIVE_SESSION_STARTS; + } + if (latest === undefined) return desired; + const rendered = systemReminderContent(latest); + if ( + !forceRefresh && + (rendered === desired.trim() || rendered === `${desired}\n\n${SUPERSEDES_SUFFIX}`.trim()) + ) { + return undefined; + } + return `${desired}\n\n${SUPERSEDES_SUFFIX}`; + } + + private async resolveDesiredSessionStart( + injection: ContextInjectionContext, + forceRefresh: boolean, + ): Promise { + const snapshot = this.states.get(pluginSessionStartSnapshotKey); + if (!forceRefresh && snapshot.initialized) return snapshot.content; + if (!forceRefresh && injection.lastInjection !== undefined) { + const rendered = systemReminderContent(injection.lastInjection); + if (rendered !== undefined) { + const content = frozenSessionStartContent(rendered); + this.recordSessionStartSnapshot(content); + return content; + } + } + const content = await this.renderSessionStartReminder(); + this.recordSessionStartSnapshot(content); + return content; + } + + private recordSessionStartSnapshot(content: string | undefined): void { + void this.dispatcher.dispatch( + new PluginSessionStartEvent({ + agentId: this.scopeContext.agentId, + content: content ?? null, + }), + ); + } +} + +function frozenSessionStartContent(rendered: string): string | undefined { + if (rendered === NO_ACTIVE_SESSION_STARTS) return undefined; + const suffix = `\n\n${SUPERSEDES_SUFFIX}`; + return rendered.endsWith(suffix) ? rendered.slice(0, -suffix.length) : rendered; +} + +interface RenderPluginSessionStartReminderInput { + readonly sessionStarts: readonly EnabledPluginSessionStart[]; + readonly catalog: SkillCatalog | undefined; + readonly log?: { warn(message: string, payload?: unknown): void }; + readonly sessionId?: string; + readonly warnedSkills: Set; +} + +function renderPluginSessionStartReminder( + input: RenderPluginSessionStartReminderInput, +): string | undefined { + const { sessionStarts, catalog, log, sessionId, warnedSkills } = input; + if (sessionStarts.length === 0) return undefined; + if (catalog === undefined) return undefined; + const blocks: string[] = []; + for (const sessionStart of sessionStarts) { + const skill = catalog.getPluginSkill(sessionStart.pluginId, sessionStart.skillName); + if (skill === undefined) { + const key = `${sessionStart.pluginId}:${sessionStart.skillName}`; + if (!warnedSkills.has(key)) { + warnedSkills.add(key); + log?.warn('plugin sessionStart skill not found', { + pluginId: sessionStart.pluginId, + skillName: sessionStart.skillName, + }); + } + continue; + } + blocks.push( + renderSessionStartBlock(sessionStart, skill, catalog.renderSkillPrompt(skill, '', { sessionId })), + ); + } + return blocks.length > 0 ? blocks.join('\n') : undefined; +} + +function shouldNeutralizePluginSessionStart( + history: readonly { readonly origin?: { readonly kind: string; readonly variant?: string } }[], +): boolean { + return history.some((message) => { + const kind = message.origin?.kind; + if (kind === 'injection') { + return message.origin?.variant === SESSION_START_INJECTION_VARIANT; + } + return kind === 'compaction_summary'; + }); +} + +function renderSessionStartBlock( + sessionStart: EnabledPluginSessionStart, + skill: SkillDefinition, + skillContent: string, +): string { + return ( + `\n${skillContent}\n` + ); +} + +registerScopedService( + LifecycleScope.Agent, + IAgentPluginService, + AgentPluginService, + ScopeActivation.OnScopeCreated, + 'agentPlugin', +); diff --git a/packages/agent-core-v2/src/agent/plugin/index.ts b/packages/agent-core-v2/src/agent/plugin/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..a6e2b9b335f52a88c8cfcd96c1074180b96991a1 --- /dev/null +++ b/packages/agent-core-v2/src/agent/plugin/index.ts @@ -0,0 +1,2 @@ +export * from './agentPlugin'; +export * from './agentPluginService'; diff --git a/packages/agent-core-v2/src/agent/pluginCommand/pluginCommand.ts b/packages/agent-core-v2/src/agent/pluginCommand/pluginCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..23d13f5c90d2321aa5410c3a1c9042e0b1182b99 --- /dev/null +++ b/packages/agent-core-v2/src/agent/pluginCommand/pluginCommand.ts @@ -0,0 +1,37 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { AgentEvent2 } from '#/app/event/event2'; + +export interface ActivatePluginCommandPayload { + readonly pluginId: string; + readonly commandName: string; + readonly args?: string | undefined; +} + +export interface PluginCommandActivatedPayload { + readonly agentId: string; + readonly activationId: string; + readonly pluginId: string; + readonly commandName: string; + readonly commandArgs?: string; + readonly trigger: 'user-slash'; +} + +export class PluginCommandActivated extends AgentEvent2 { + static override readonly type = 'plugin_command.activated'; + static override readonly observable = true; +} +export interface PluginCommandActivated extends PluginCommandActivatedPayload {} + +export interface PluginCommandActivatedEvent extends Omit { + readonly type: 'plugin_command.activated'; +} + +export interface IAgentPluginCommandService { + readonly _serviceBrand: undefined; + + activate(payload: ActivatePluginCommandPayload): Promise; +} + +export const IAgentPluginCommandService: ServiceIdentifier = + createDecorator('agentPluginCommandService'); diff --git a/packages/agent-core-v2/src/agent/pluginCommand/pluginCommandService.ts b/packages/agent-core-v2/src/agent/pluginCommand/pluginCommandService.ts new file mode 100644 index 0000000000000000000000000000000000000000..ded3d5f78782c774e972f34e33029283965e2d3c --- /dev/null +++ b/packages/agent-core-v2/src/agent/pluginCommand/pluginCommandService.ts @@ -0,0 +1,101 @@ +import { randomUUID } from 'node:crypto'; + +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IEventService } from '#/app/event/event'; +import { ErrorCodes, Error2 } from '#/errors'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; +import { expandCommandArguments } from '#/app/plugin/commands'; +import { IPluginService } from '#/app/plugin/plugin'; +import { IAgentLoopService } from '#/agent/loop/loop'; +import { promptMetadataTextFromText } from '#/agent/prompt/promptMetadataText'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { applyPromptMetadataUpdate } from '#/session/sessionMetadata/promptMetadata'; +import { IEventDispatcher } from '#/state/eventDispatcher'; + +import { + IAgentPluginCommandService, + PluginCommandActivated, + type ActivatePluginCommandPayload, +} from './pluginCommand'; + +export class AgentPluginCommandService implements IAgentPluginCommandService { + declare readonly _serviceBrand: undefined; + + constructor( + @IPluginService private readonly plugins: IPluginService, + @IAgentLoopService private readonly loop: IAgentLoopService, + @IEventDispatcher private readonly dispatcher: IEventDispatcher, + @ISessionMetadata private readonly metadata: ISessionMetadata, + @IEventService private readonly eventService: IEventService, + @ISessionContext private readonly sessionContext: ISessionContext, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + ) { } + + async activate(payload: ActivatePluginCommandPayload): Promise { + const commands = await this.plugins.listPluginCommands(); + const def = commands.find( + (command) => command.pluginId === payload.pluginId && command.name === payload.commandName, + ); + if (def === undefined) { + throw new Error2( + ErrorCodes.REQUEST_INVALID, + `Plugin command "${payload.pluginId}:${payload.commandName}" was not found`, + ); + } + const commandArgs = payload.args ?? ''; + const expanded = expandCommandArguments(def.body, commandArgs); + const origin = { + kind: 'plugin_command' as const, + activationId: randomUUID(), + pluginId: payload.pluginId, + commandName: payload.commandName, + commandArgs: payload.args, + trigger: 'user-slash' as const, + }; + await this.dispatcher.dispatch( + new PluginCommandActivated({ + agentId: this.scopeContext.agentId, + activationId: origin.activationId, + pluginId: origin.pluginId, + commandName: origin.commandName, + commandArgs: origin.commandArgs, + trigger: origin.trigger, + }), + ); + this.loop.submit({ + message: { role: 'user', content: [{ type: 'text', text: expanded }] }, + meta: { origin, tracked: true }, + }); + if (this.scopeContext.agentId === MAIN_AGENT_ID) { + await applyPromptMetadataUpdate( + { + metadata: this.metadata, + eventService: this.eventService, + sessionId: this.sessionContext.sessionId, + }, + promptMetadataTextFromPluginCommand(payload), + ); + } + } +} + +function promptMetadataTextFromPluginCommand( + payload: ActivatePluginCommandPayload, +): string | undefined { + const args = payload.args?.trim(); + const command = `/${payload.pluginId}:${payload.commandName}`; + return promptMetadataTextFromText( + args === undefined || args.length === 0 ? command : `${command} ${args}`, + ); +} + +registerScopedService( + LifecycleScope.Agent, + IAgentPluginCommandService, + AgentPluginCommandService, + ScopeActivation.OnScopeCreated, + 'pluginCommand', +); diff --git a/packages/agent-core-v2/src/agent/profile/context.ts b/packages/agent-core-v2/src/agent/profile/context.ts new file mode 100644 index 0000000000000000000000000000000000000000..f6455f9f0e084bd8a4a6507f74d9978fb2cbdfad --- /dev/null +++ b/packages/agent-core-v2/src/agent/profile/context.ts @@ -0,0 +1,430 @@ +import { basename, dirname, join, normalize } from 'pathe'; + +import { findGitWorkTree } from '#/app/git/workTree'; +import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; + +import type { SystemPromptContext } from './profile'; + +export const AGENTS_MD_RECOMMENDED_MAX_BYTES = 32 * 1024; + +export const LIST_DIR_ROOT_WIDTH = 30; +export const LIST_DIR_CHILD_WIDTH = 10; + +interface ProfileContextDeps { + readonly fs: IHostFileSystem; + readonly homeDir: string; +} + +export type { ProfileContextDeps }; + +export interface PreparedSystemPromptContext extends SystemPromptContext { + readonly cwdListing?: string; + readonly agentsMd?: string; + readonly agentsMdPaths?: readonly string[]; + readonly additionalDirsInfo?: string; + readonly agentsMdWarning?: string; +} + +export interface PrepareSystemPromptContextOptions { + readonly additionalDirs?: readonly string[]; + readonly preloadedAgentsMd?: LoadedAgentsMd; +} + +export async function prepareSystemPromptContext( + deps: ProfileContextDeps, + workDir: string, + brandHome?: string, + options?: PrepareSystemPromptContextOptions, +): Promise { + const additionalDirs = dedupeDirs(options?.additionalDirs ?? []); + const [cwdListing, agentsMdResult, additionalDirsInfo] = await Promise.all([ + listDirectory(deps, workDir, { collapseHiddenDirs: true }), + options?.preloadedAgentsMd !== undefined + ? Promise.resolve(options.preloadedAgentsMd) + : loadAgentsMdForRoots(deps, brandHome, [workDir]), + loadAdditionalDirsInfo(deps, additionalDirs), + ]); + return { + cwdListing, + agentsMd: agentsMdResult.content, + agentsMdPaths: agentsMdResult.paths, + additionalDirsInfo, + agentsMdWarning: agentsMdResult.warning, + }; +} + +export async function loadAgentsMd( + deps: ProfileContextDeps, + workDir: string, + brandHome?: string, +): Promise { + const result = await loadAgentsMdForRoots(deps, brandHome, [workDir]); + return result.content; +} + +export async function loadAgentsMdDetailed( + deps: ProfileContextDeps, + workDir: string, + brandHome?: string, +): Promise { + return loadAgentsMdForRoots(deps, brandHome, [workDir]); +} + +export interface LoadedAgentsMd { + readonly content: string; + readonly warning: string | undefined; + readonly paths: readonly string[]; +} + +export const AGENTS_MD_PLAIN_NAMES = ['AGENTS.md', 'agents.md'] as const; + +export function dotKimiAgentsMdPath(dir: string): string { + return join(dir, '.kimi-code', 'AGENTS.md'); +} + +export function agentsMdCandidatePaths(dir: string): string[] { + return [dotKimiAgentsMdPath(dir), ...AGENTS_MD_PLAIN_NAMES.map((name) => join(dir, name))]; +} + +export function extractAgentsMdPathsFromSystemPrompt(systemPrompt: string): string[] { + const paths: string[] = []; + const seen = new Set(); + for (const match of systemPrompt.matchAll(/^$/gm)) { + const path = match[1]; + if ( + path === undefined || + !AGENTS_MD_PLAIN_NAMES.some((candidate) => candidate === basename(path)) + ) { + continue; + } + const normalized = normalize(path); + if (seen.has(normalized)) continue; + seen.add(normalized); + paths.push(normalized); + } + return paths; +} + +export async function findAgentsMdInDir( + deps: { readonly fs: IHostFileSystem }, + dir: string, +): Promise { + const found: string[] = []; + const dotKimi = dotKimiAgentsMdPath(dir); + if (await isNonEmptyFile(deps, dotKimi)) found.push(dotKimi); + for (const fileName of AGENTS_MD_PLAIN_NAMES) { + const candidate = join(dir, fileName); + if (await isNonEmptyFile(deps, candidate)) { + found.push(candidate); + break; + } + } + return found; +} + +async function isNonEmptyFile( + deps: { readonly fs: IHostFileSystem }, + path: string, +): Promise { + try { + const content = await deps.fs.readText(path, { errors: 'ignore' }); + return content.trim().length > 0; + } catch { + return false; + } +} + +export async function loadAgentsMdForRoots( + deps: ProfileContextDeps, + brandHome: string | undefined, + workDirs: readonly string[], +): Promise { + const discovered: AgentFile[] = []; + const seen = new Set(); + const loadWarnings: string[] = []; + const warnLoad = (message: string): void => { + loadWarnings.push(message); + }; + + const collect = async (path: string): Promise => { + const file = await readAgentFile(deps, path, warnLoad); + if (file === undefined) return false; + const key = normalize(file.path); + if (seen.has(key)) return false; + seen.add(key); + discovered.push(file); + return true; + }; + + const realHome = deps.homeDir; + const brandDir = brandHome ?? join(realHome, '.kimi-code'); + await collect(join(brandDir, 'AGENTS.md')); + + const genericDirs = [join(realHome, '.agents')]; + const genericFiles = genericDirs.flatMap((dir) => + AGENTS_MD_PLAIN_NAMES.map((name) => join(dir, name)), + ); + for (const file of genericFiles) { + if (await collect(file)) break; + } + + for (const workDir of workDirs) { + const rootWorkDir = normalize(workDir); + const projectRoot = (await findGitWorkTree(deps.fs, rootWorkDir))?.root ?? rootWorkDir; + const dirs = dirsRootToLeaf(rootWorkDir, projectRoot); + + for (const dir of dirs) { + await collect(dotKimiAgentsMdPath(dir)); + for (const fileName of AGENTS_MD_PLAIN_NAMES) { + if (await collect(join(dir, fileName))) break; + } + } + } + + const content = renderAgentFiles(discovered); + const totalBytes = byteLength(content); + if (totalBytes > AGENTS_MD_RECOMMENDED_MAX_BYTES) { + loadWarnings.push( + `AGENTS.md total ${formatKB(totalBytes)} KB exceeds the recommended ` + + `${formatKB(AGENTS_MD_RECOMMENDED_MAX_BYTES)} KB. Large instruction files ` + + `increase cost and may impact performance; consider trimming.`, + ); + } + const warning = loadWarnings.length > 0 ? loadWarnings.join('\n') : undefined; + const paths = discovered.map((file) => normalize(file.path)); + return { content, warning, paths }; +} + +export interface AgentsMdWatchRoot { + readonly root: string; + readonly candidates: readonly string[]; +} + +export async function agentsMdWatchRoots( + deps: ProfileContextDeps, + workDir: string, + brandHome?: string, +): Promise { + const realHome = deps.homeDir; + const brandDir = brandHome ?? join(realHome, '.kimi-code'); + const plan: AgentsMdWatchRoot[] = [ + { root: brandDir, candidates: [join(brandDir, 'AGENTS.md')] }, + { + root: realHome, + candidates: [join(realHome, '.agents', 'AGENTS.md'), join(realHome, '.agents', 'agents.md')], + }, + ]; + const rootWorkDir = normalize(workDir); + const projectRoot = (await findGitWorkTree(deps.fs, rootWorkDir))?.root ?? rootWorkDir; + const projectCandidates: string[] = []; + for (const dir of dirsRootToLeaf(rootWorkDir, projectRoot)) { + projectCandidates.push( + join(dir, '.kimi-code', 'AGENTS.md'), + join(dir, 'AGENTS.md'), + join(dir, 'agents.md'), + ); + } + plan.push({ root: projectRoot, candidates: projectCandidates }); + return plan; +} + +async function loadAdditionalDirsInfo( + deps: ProfileContextDeps, + additionalDirs: readonly string[], +): Promise { + const sections = await Promise.all( + additionalDirs.map(async (dir) => { + const listing = await listDirectory(deps, dir); + return `### ${dir}\n${listing}`; + }), + ); + return sections.join('\n\n'); +} + +export async function findProjectRoot( + deps: { readonly fs: IHostFileSystem }, + workDir: string, +): Promise { + const rootWorkDir = normalize(workDir); + return (await findGitWorkTree(deps.fs, rootWorkDir))?.root ?? rootWorkDir; +} + +export function dirsRootToLeaf(workDir: string, projectRoot: string): string[] { + const dirs: string[] = []; + let current = normalize(workDir); + + while (true) { + dirs.push(current); + if (current === projectRoot) break; + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + + return dirs.toReversed(); +} + +interface AgentFile { + readonly path: string; + readonly content: string; +} + +async function readAgentFile( + deps: ProfileContextDeps, + path: string, + warn: (message: string) => void, +): Promise { + if (!(await isFile(deps, path))) { + if (await entryExists(deps, path)) { + warn(`Instruction file at ${path} exists but is not a readable regular file; skipping.`); + } + return undefined; + } + let content: string; + try { + content = (await deps.fs.readText(path, { errors: 'ignore' })).trim(); + } catch { + warn(`Instruction file at ${path} could not be read; skipping.`); + return undefined; + } + if (content.length === 0) return undefined; + return { path, content }; +} + +async function pathExists(deps: { readonly fs: IHostFileSystem }, path: string): Promise { + try { + await deps.fs.lstat(path); + return true; + } catch { + return false; + } +} + +async function entryExists(deps: { readonly fs: IHostFileSystem }, path: string): Promise { + return pathExists(deps, path); +} + +async function isFile(deps: { readonly fs: IHostFileSystem }, path: string): Promise { + try { + const stat = await deps.fs.stat(path); + return stat.isFile; + } catch { + return false; + } +} + +function renderAgentFiles(files: readonly AgentFile[]): string { + if (files.length === 0) return ''; + return files.map((file) => `${annotationFor(file.path)}${file.content}`).join('\n\n'); +} + +function byteLength(text: string): number { + return Buffer.byteLength(text, 'utf8'); +} + +function formatKB(bytes: number): string { + const kb = bytes / 1024; + return Number.isInteger(kb) ? String(kb) : kb.toFixed(1); +} + +function annotationFor(path: string): string { + return `\n`; +} + +function dedupeDirs(dirs: readonly string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const dir of dirs) { + if (typeof dir !== 'string') continue; + const trimmed = dir.trim(); + if (trimmed.length === 0 || seen.has(trimmed)) continue; + seen.add(trimmed); + result.push(trimmed); + } + return result; +} + +interface ListDirectoryOptions { + readonly collapseHiddenDirs?: boolean; +} + +interface Entry { + readonly name: string; + readonly isDir: boolean; +} + +async function collectEntries( + deps: ProfileContextDeps, + dirPath: string, + maxWidth: number, +): Promise<{ entries: Entry[]; total: number; readable: boolean }> { + const all: Entry[] = []; + try { + const dirents = await deps.fs.readdir(dirPath); + for (const d of dirents) { + all.push({ name: d.name, isDir: d.isDirectory }); + } + } catch { + return { entries: [], total: 0, readable: false }; + } + all.sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return a.name.localeCompare(b.name); + }); + return { entries: all.slice(0, maxWidth), total: all.length, readable: true }; +} + +function shouldCollapseDirectory(entry: Entry, options: ListDirectoryOptions): boolean { + return options.collapseHiddenDirs === true && entry.isDir && entry.name.startsWith('.'); +} + +async function listDirectory( + deps: ProfileContextDeps, + workDir: string, + options: ListDirectoryOptions = {}, +): Promise { + const lines: string[] = []; + const { entries, total, readable } = await collectEntries(deps, workDir, LIST_DIR_ROOT_WIDTH); + if (!readable) return '[not readable]'; + const remaining = total - entries.length; + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (entry === undefined) continue; + const { name, isDir } = entry; + const isLast = i === entries.length - 1 && remaining === 0; + const connector = isLast ? '└── ' : '├── '; + + if (isDir) { + lines.push(`${connector}${name}/`); + if (shouldCollapseDirectory(entry, options)) continue; + const childPrefix = isLast ? ' ' : '│ '; + const childDir = join(workDir, name); + const child = await collectEntries(deps, childDir, LIST_DIR_CHILD_WIDTH); + if (!child.readable) { + lines.push(`${childPrefix}└── [not readable]`); + continue; + } + const childRemaining = child.total - child.entries.length; + for (let j = 0; j < child.entries.length; j++) { + const ce = child.entries[j]; + if (ce === undefined) continue; + const cIsLast = j === child.entries.length - 1 && childRemaining === 0; + const cConnector = cIsLast ? '└── ' : '├── '; + const suffix = ce.isDir ? '/' : ''; + lines.push(`${childPrefix}${cConnector}${ce.name}${suffix}`); + } + if (childRemaining > 0) { + lines.push(`${childPrefix}└── ... and ${String(childRemaining)} more`); + } + } else { + lines.push(`${connector}${name}`); + } + } + + if (remaining > 0) { + lines.push(`└── ... and ${String(remaining)} more entries`); + } + + return lines.length > 0 ? lines.join('\n') : '(empty directory)'; +} diff --git a/packages/agent-core-v2/src/agent/profile/errors.ts b/packages/agent-core-v2/src/agent/profile/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f84b876f63743f9c7542c43fca270563aa0e1cb --- /dev/null +++ b/packages/agent-core-v2/src/agent/profile/errors.ts @@ -0,0 +1,14 @@ +import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; + +export const ProfileErrors = { + codes: { + MODEL_NOT_CONFIGURED: 'model.not_configured', + MODEL_CONFIG_INVALID: 'model.config_invalid', + THINKING_ALIAS_CONFLICT: 'profile.thinking_alias_conflict', + PROFILE_UNKNOWN: 'profile.unknown', + PROFILE_ALREADY_BOUND: 'profile.already_bound', + PROFILE_NOT_BOUND: 'profile.not_bound', + }, +} as const satisfies ErrorDomain; + +registerErrorDomain(ProfileErrors); diff --git a/packages/agent-core-v2/src/agent/profile/profile.ts b/packages/agent-core-v2/src/agent/profile/profile.ts new file mode 100644 index 0000000000000000000000000000000000000000..b70607a65bc1b4fa4086897aa47d5600e5d321bd --- /dev/null +++ b/packages/agent-core-v2/src/agent/profile/profile.ts @@ -0,0 +1,143 @@ +import type { + AgentProfile, + AgentProfileContext, + EnvironmentDisclosureSnapshot, +} from '#/app/agentProfileCatalog/agentProfileCatalog'; +import type { ModelCapability } from '#/llm-adapter/contract/capability'; +import type { ThinkingEffort } from '#human/llm/thinking'; +import type { ModelRequestParams } from '#/llm-adapter/model/model-requester'; + +import { createDecorator } from "#/_base/di/instantiation"; +import type { ErrorCode } from '#/errors'; +import { Error2 } from '#/_base/errors/errors'; + +import { ProfileErrors } from './errors'; + +export { ProfileErrors } from './errors'; + +export type ProfileErrorCode = (typeof ProfileErrors.codes)[keyof typeof ProfileErrors.codes]; + +export class ProfileError extends Error2 { + constructor(code: ProfileErrorCode, message: string, details?: Record) { + super(code as ErrorCode, message, { details }); + this.name = 'ProfileError'; + } +} + +export interface AgentConfigData { + modelAlias?: string; + modelCapabilities: ModelCapability; + profileName?: string; + thinkingLevel: string; + systemPrompt: string; +} + +export type AgentConfigUpdateData = Partial<{ + modelAlias: string; + profileName: string; + thinkingLevel: string; + systemPrompt: string; +}>; + +export interface SystemPromptContext extends AgentProfileContext { + readonly agentsMdWarning?: string; + readonly agentsMdPaths?: readonly string[]; +} + +export type ResolvedAgentProfile = AgentProfile; + +export interface ProfileData extends AgentConfigData { + readonly agentsMdPaths?: readonly string[]; + readonly activeToolNames?: readonly string[]; + readonly disallowedTools?: readonly string[]; + readonly subagents?: readonly string[]; + readonly environmentDisclosure?: EnvironmentDisclosureSnapshot; + readonly renderGeneration?: number; +} + +export type ProfileUpdateData = Partial<{ + modelAlias: string; + profileName: string; + thinkingLevel: string; + systemPrompt: string; + environmentDisclosure: EnvironmentDisclosureSnapshot; + agentsMdPaths: readonly string[]; + disallowedTools: readonly string[]; + activeToolNames: readonly string[]; +}>; + +export interface ProfileBindingSnapshot { + readonly modelAlias?: string; + readonly profileName?: string; + readonly thinkingLevel: string; + readonly systemPrompt: string; + readonly environmentDisclosure?: EnvironmentDisclosureSnapshot; + readonly renderGeneration?: number; + readonly agentsMdPaths?: readonly string[]; + readonly activeToolNames?: readonly string[]; + readonly disallowedTools?: readonly string[]; + readonly subagents?: readonly string[]; +} + +export interface ProfileServiceOptions { + readonly emitStatusUpdated?: () => void; +} + +export interface ApplyProfileOptions { + readonly additionalDirs?: readonly string[]; +} + +export interface ProfileModelContext { + readonly modelAlias: string; + readonly modelCapabilities: ModelCapability; + readonly maxOutputSize: number | undefined; + readonly alwaysThinking: boolean | undefined; + readonly thinkingLevel: ThinkingEffort; + readonly reservedContextSize: number | undefined; + readonly compactionTriggerRatio: number | undefined; + readonly compactionMaxAttempts: number | undefined; +} + +export interface ProfileSetModelResult { + readonly model: string; + readonly providerName?: string | undefined; +} + +export interface BindAgentInput { + readonly profile: string; + readonly model?: string; + readonly thinking?: string; + readonly strictThinking?: boolean; +} + +export interface IAgentProfileService { + readonly _serviceBrand: undefined; + + configure(options: ProfileServiceOptions): void; + update(changed: ProfileUpdateData): void; + applyBindingSnapshot(snapshot: ProfileBindingSnapshot): void; + bind(input: BindAgentInput): Promise; + setModel(model: string): Promise; + setThinking(level: string): void; + republishStatus(): void; + getModel(): string; + useProfile(profile: ResolvedAgentProfile, context: SystemPromptContext): void; + applyProfile(profile: ResolvedAgentProfile, options?: ApplyProfileOptions): Promise; + getAgentsMdWarning(): string | undefined; + data(): ProfileData; + getEffectiveThinkingLevel(): ThinkingEffort; + resolveModelContext(): ProfileModelContext; + resolveRequestParams(): ModelRequestParams; + getModelCapabilities(): ModelCapability; + getModelProviderType(alias?: string): string | undefined; + getMaxOutputSize(): number | undefined; + hasModel(): boolean; + isRunnable(): boolean; + hasProvider(): boolean; + getSystemPrompt(): string; + getActiveToolNames(): readonly string[] | undefined; + addActiveTool(name: string): void; + removeActiveTool(name: string): void; +} + +export const IAgentProfileService = createDecorator('agentProfileService'); diff --git a/packages/agent-core-v2/src/agent/profile/profileOps.ts b/packages/agent-core-v2/src/agent/profile/profileOps.ts new file mode 100644 index 0000000000000000000000000000000000000000..ef2246283465ea81babfaead5df9d46605c8ded8 --- /dev/null +++ b/packages/agent-core-v2/src/agent/profile/profileOps.ts @@ -0,0 +1,227 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { nothing, original } from 'immer'; +import { z } from 'zod'; + +import type { EnvironmentDisclosureSnapshot } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { AgentEvent2 } from '#/app/event/event2'; +import type { ThinkingEffort } from '#human/llm/thinking'; +import { defineState } from '#/state/state'; + +import { ProfileError, ProfileErrors } from './profile'; + +export interface ProfileModelState { + readonly modelAlias?: string; + readonly profileName?: string; + readonly thinkingLevel: string; + readonly systemPrompt: string; + readonly environmentDisclosure?: EnvironmentDisclosureSnapshot; + readonly renderGeneration: number; + readonly agentsMdPaths?: readonly string[]; + readonly disallowedTools?: readonly string[]; + readonly subagents?: readonly string[]; +} + +const profileBindSchema = z.object({ + agentId: z.string(), + modelAlias: z.string().optional(), + profileName: z.string().optional(), + thinkingEffort: z.custom(), + systemPrompt: z.string(), + environmentDisclosure: z.custom().optional(), + renderGeneration: z.number().optional(), + agentsMdPaths: z.array(z.string()).readonly().optional(), + activeToolNames: z.array(z.string()).readonly().optional(), + disallowedTools: z.array(z.string()).readonly(), + subagents: z.array(z.string()).readonly().optional(), +}); + +export class ProfileBind extends AgentEvent2> { + static override readonly type = 'profile.bind'; + static override readonly durable = true; + static override readonly schema = profileBindSchema; +} +export interface ProfileBind { + readonly agentId: string; + readonly modelAlias?: string; + readonly profileName?: string; + readonly thinkingEffort: ThinkingEffort; + readonly systemPrompt: string; + readonly environmentDisclosure?: EnvironmentDisclosureSnapshot; + readonly renderGeneration?: number; + readonly agentsMdPaths?: readonly string[]; + readonly activeToolNames?: readonly string[]; + readonly disallowedTools: readonly string[]; + readonly subagents?: readonly string[]; +} + +const configUpdateSchema = z.object({ + agentId: z.string(), + modelAlias: z.string().optional(), + profileName: z.string().optional(), + thinkingEffort: z.custom().optional(), + thinkingLevel: z.custom().optional(), + systemPrompt: z.string().optional(), + environmentDisclosure: z.custom().optional(), + renderGeneration: z.number().optional(), + agentsMdPaths: z.array(z.string()).readonly().optional(), + disallowedTools: z.array(z.string()).readonly().optional(), +}); + +export type ConfigUpdatePayload = z.infer; + +export class ConfigUpdate extends AgentEvent2 { + static override readonly type = 'config.update'; + static override readonly durable = true; + static override readonly schema = configUpdateSchema; +} +export interface ConfigUpdate { + readonly agentId: string; + readonly modelAlias?: string; + readonly profileName?: string; + readonly thinkingEffort?: ThinkingEffort; + readonly thinkingLevel?: ThinkingEffort; + readonly systemPrompt?: string; + readonly environmentDisclosure?: EnvironmentDisclosureSnapshot; + readonly renderGeneration?: number; + readonly agentsMdPaths?: readonly string[]; + readonly disallowedTools?: readonly string[]; +} + +const toolsSetActiveToolsSchema = z.object({ + agentId: z.string(), + names: z.array(z.string()).readonly(), +}); + +export class ToolsSetActiveTools extends AgentEvent2> { + static override readonly type = 'tools.set_active_tools'; + static override readonly durable = true; + static override readonly schema = toolsSetActiveToolsSchema; +} +export interface ToolsSetActiveTools { + readonly agentId: string; + readonly names: readonly string[]; +} + +const toolsResetActiveToolsSchema = z.object({ agentId: z.string() }); + +export class ToolsResetActiveTools extends AgentEvent2< + z.infer +> { + static override readonly type = 'tools.reset_active_tools'; + static override readonly durable = true; + static override readonly schema = toolsResetActiveToolsSchema; +} +export interface ToolsResetActiveTools { + readonly agentId: string; +} + +export interface WarningIssuedPayload { + readonly agentId: string; + readonly message: string; + readonly code?: string; +} + +export class WarningIssued extends AgentEvent2 { + static override readonly type = 'warning'; + static override readonly observable = true; +} +export interface WarningIssued extends WarningIssuedPayload {} + +export const profileKey = defineState( + 'profile', + (): ProfileModelState => ({ + thinkingLevel: 'off', + systemPrompt: '', + renderGeneration: 0, + }), +).replayable({ schema: z.custom() }) + .on(ProfileBind, (s, e) => ({ + modelAlias: e.modelAlias ?? s.modelAlias, + profileName: e.profileName ?? s.profileName, + thinkingLevel: e.thinkingEffort, + systemPrompt: e.systemPrompt, + environmentDisclosure: e.environmentDisclosure, + renderGeneration: e.renderGeneration ?? s.renderGeneration + 1, + agentsMdPaths: e.agentsMdPaths ?? s.agentsMdPaths, + disallowedTools: e.disallowedTools, + subagents: e.subagents, + })) + .on(ConfigUpdate, (s, e) => { + if (e.modelAlias !== undefined && e.modelAlias !== s.modelAlias) { + s.modelAlias = e.modelAlias; + } + if (e.profileName !== undefined && e.profileName !== s.profileName) { + s.profileName = e.profileName; + } + const thinkingLevel = configUpdateThinkingLevel(e); + if (thinkingLevel !== undefined && thinkingLevel !== s.thinkingLevel) { + s.thinkingLevel = thinkingLevel; + } + if ( + e.systemPrompt !== undefined && + (e.systemPrompt !== s.systemPrompt || + e.environmentDisclosure !== undefined || + e.renderGeneration !== undefined) + ) { + s.systemPrompt = e.systemPrompt; + s.environmentDisclosure = e.environmentDisclosure; + s.renderGeneration = e.renderGeneration ?? s.renderGeneration + 1; + } + if (e.agentsMdPaths !== undefined && !stringArrayEqual(e.agentsMdPaths, s.agentsMdPaths)) { + s.agentsMdPaths = e.agentsMdPaths as string[]; + } + if ( + e.disallowedTools !== undefined && + !stringArrayEqual(e.disallowedTools, s.disallowedTools) + ) { + s.disallowedTools = e.disallowedTools as string[]; + } + }); + +function stringArrayEqual( + a: readonly string[] | undefined, + b: readonly string[] | undefined, +): boolean { + if (a === b) return true; + if (a === undefined || b === undefined) return false; + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +function configUpdateThinkingLevel(e: ConfigUpdatePayload): ThinkingEffort | undefined { + if (e.thinkingEffort !== undefined && e.thinkingLevel !== undefined) { + if (e.thinkingEffort !== e.thinkingLevel) { + throw new ProfileError( + ProfileErrors.codes.THINKING_ALIAS_CONFLICT, + `config.update has conflicting thinkingEffort (${e.thinkingEffort}) and legacy thinkingLevel (${e.thinkingLevel})`, + { + type: 'config.update', + thinkingEffort: e.thinkingEffort, + thinkingLevel: e.thinkingLevel, + }, + ); + } + return e.thinkingEffort; + } + if (e.thinkingEffort !== undefined) return e.thinkingEffort; + return e.thinkingLevel; +} + +export type ActiveToolsState = readonly string[] | undefined; + +export const profileActiveToolsKey = defineState( + 'profile.activeTools', + (): ActiveToolsState => undefined, +).replayable({ schema: z.custom() }) + .on(ToolsSetActiveTools, (s, e) => { + if (s !== undefined && e.names === original(s)) return; + return e.names; + }) + .on(ToolsResetActiveTools, (s) => { + if (s === undefined) return; + return nothing as unknown as ActiveToolsState; + }) + .on(ProfileBind, (s, e) => + e.activeToolNames === undefined && s !== undefined + ? (nothing as unknown as ActiveToolsState) + : e.activeToolNames, + ); diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts new file mode 100644 index 0000000000000000000000000000000000000000..887907d30e724395612254c1e28a833c1e10a733 --- /dev/null +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -0,0 +1,934 @@ +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { defineState } from '#/state/state'; +import { UNKNOWN_CAPABILITY, type ModelCapability } from '#/llm-adapter/contract/capability'; +import { type ThinkingEffort } from '#human/llm/thinking'; +import { IModelCatalog, type Model } from '#/llm-adapter/model/catalog'; +import { type ModelOverrides } from '#/llm-adapter/model/model.types'; +import { type ModelRequestParams, type SamplingOptions } from '#/llm-adapter/model/model-requester'; +import { IProtocolAdapterRegistry } from '#/llm-adapter/protocol/protocol'; +import { + drivesThinkingThroughTraits, + modelSupportsThinkingEffort, + normalizeRequestedThinkingEffort, + resolveForcedThinkingEffort, + resolveThinkingEffortForModel, + resolveThinkingKeep, + requiresStrictThinkingValidation, + type ThinkingConfig, +} from '#/llm-adapter/model/thinking'; +import { THINKING_SECTION } from '#/app/kosongConfig/configSection'; +import { DEFAULT_AGENT_PROFILE_NAME } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { IBuiltinAgentProfileLoader } from '#/app/agentProfileCatalog/builtinAgentProfileLoader'; +import { ErrorCodes, Error2 } from "#/errors"; +import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigService } from '#/app/config/config'; +import type { LoopControl } from '#/agent/loop/configSection'; +import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; +import { RuntimeWorkspaceView } from '#/runtime/runtimeWorkspaceView'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import type { ToolSource } from '#/tool/toolContract'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; +import { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog'; +import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; +import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; +import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; +import { IPluginService } from '#/app/plugin/plugin'; +import type { ResolvedAgentProfile, SystemPromptContext } from '#/agent/profile/profile'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { IAgentAgentsMdReminderService } from '#/agent/agentsMdReminder/agentsMdReminder'; + +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { IEventDispatcher } from '#/state/eventDispatcher'; +import { + extractAgentsMdPathsFromSystemPrompt, + prepareSystemPromptContext, + type LoadedAgentsMd, +} from './context'; +import type { + ApplyProfileOptions, + BindAgentInput, + ProfileBindingSnapshot, + ProfileData, + ProfileModelContext, + ProfileServiceOptions, + ProfileSetModelResult, + ProfileUpdateData, +} from './profile'; +import { IAgentProfileService, ProfileError, ProfileErrors } from './profile'; +import { TOOLS_SECTION, type ToolsConfig } from '#/agent/toolPolicy/configSection'; +import { isToolActiveComposed, findInactiveToolPatterns, literalToolNames, type InactiveToolPattern } from '#/agent/toolPolicy/evaluate'; +import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; +import { ISessionNotify } from '#/features/notify/sessionNotify'; +import { NOTIFY_USER_TOOL_NAME } from '#/features/notify/tools/notify-user/notify-user'; +import { renderAgentProfilePrompt } from '#/app/agentProfileCatalog/profile-shared'; +import { getAgentToolContributions } from '#/agent/toolRegistry/toolContribution'; +import { + profileActiveToolsKey, + ConfigUpdate, + ProfileBind, + profileKey, + ToolsResetActiveTools, + ToolsSetActiveTools, + WarningIssued, + type ActiveToolsState, + type ConfigUpdatePayload, + type ProfileModelState, +} from './profileOps'; + +import { AgentStatusUpdated } from '#/agent/usage/usageEvents'; + +export type { WarningEvent } from '#/errors'; + +function describeInactiveToolPattern( + context: string, + field: string, + issue: InactiveToolPattern, +): string { + switch (issue.kind) { + case 'unknown-tool': + return `Tool pattern "${issue.pattern}" in ${context} ${field} does not match any registered or built-in tool; it will never activate anything.`; + case 'wildcard-not-mcp': + return `Tool pattern "${issue.pattern}" in ${context} ${field} uses wildcards, which only match MCP tools (names starting with "mcp__"); it will never activate anything.`; + case 'incomplete-mcp-name': + return `Tool pattern "${issue.pattern}" in ${context} ${field} matches no tool; use "${issue.pattern}__*" to match the whole MCP server.`; + } +} + +export const PLUGIN_SECTIONS_MAX_BYTES = 64 * 1024; + +export const profileActiveToolNamesOverlayKey = defineState( + 'profile.activeToolNamesOverlay', + () => undefined as readonly string[] | undefined, +); +export const profileAgentsMdWarningKey = defineState( + 'profile.agentsMdWarning', + () => undefined as string | undefined, +); +export const profileEmittedThinkingEffortWarningsKey = defineState>( + 'profile.emittedThinkingEffortWarnings', + () => new Set(), +); +export const profileEmittedToolPatternWarningsKey = defineState>( + 'profile.emittedToolPatternWarnings', + () => new Set(), +); +export const profileEmittedPluginBudgetWarningsKey = defineState>( + 'profile.emittedPluginBudgetWarnings', + () => new Set(), +); + +export class AgentProfileService extends Disposable implements IAgentProfileService { + declare readonly _serviceBrand: undefined; + + private optionsValue: ProfileServiceOptions = {}; + + private get activeToolNames(): ActiveToolsState { + return ( + this.activeToolNamesOverlay ?? + (this.states.get(profileActiveToolsKey) as ActiveToolsState) + ); + } + + private activeProfile: ResolvedAgentProfile | undefined; + + private frozenSkillListing: string | undefined; + private frozenPluginSections: string | undefined; + + constructor( + @IEventDispatcher private readonly dispatcher: IEventDispatcher, + @ITelemetryService private readonly telemetry: ITelemetryService, + @IConfigService private readonly config: IConfigService, + @IModelCatalog private readonly modelCatalog: IModelCatalog, + @IProtocolAdapterRegistry private readonly protocolAdapters: IProtocolAdapterRegistry, + @IAgentRuntimeService private readonly runtime: IAgentRuntimeService, + @ISessionContext private readonly sessionContext: ISessionContext, + @IBootstrapService private readonly bootstrap: IBootstrapService, + @ISessionNotify private readonly notify: ISessionNotify, + @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, + @ISessionAgentProfileCatalog private readonly catalog: ISessionAgentProfileCatalog, + @ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog, + @ISessionInstructionsProvider private readonly instructions: ISessionInstructionsProvider, + @ISessionToolPolicy private readonly sessionToolPolicy: ISessionToolPolicy, + @ISessionToolPolicyGate private readonly toolPolicyGate: ISessionToolPolicyGate, + @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, + @IBuiltinAgentProfileLoader private readonly builtinProfiles: IBuiltinAgentProfileLoader, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @IAgentStateService private readonly states: IAgentStateService, + @IPluginService private readonly plugins: IPluginService, + @IAgentIdentity private readonly identity: IAgentIdentity, + @IAgentAgentsMdReminderService private readonly agentsMdReminder: IAgentAgentsMdReminderService, + ) { + super(); + this.states.contributeState(profileKey); + this.states.contributeState(profileActiveToolsKey); + this.states.contributeState(profileActiveToolNamesOverlayKey); + this.states.contributeState(profileAgentsMdWarningKey); + this.states.contributeState(profileEmittedThinkingEffortWarningsKey); + this.states.contributeState(profileEmittedToolPatternWarningsKey); + this.states.contributeState(profileEmittedPluginBudgetWarningsKey); + this.configure({}); + this._register( + this.dispatcher.hooks.onDidRestore.register('profile', async (_ctx, next) => { + this.syncTelemetryModelContext(this.modelAlias); + await next(); + }), + ); + this._register( + this.config.onDidSectionChange(({ domain }) => { + if (domain === TOOLS_SECTION) { + this.publishToolPatternWarnings(); + } + }), + ); + } + + 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 { + return this.states.get(profileEmittedThinkingEffortWarningsKey); + } + + private get emittedToolPatternWarnings(): Set { + return this.states.get(profileEmittedToolPatternWarningsKey); + } + + private get emittedPluginBudgetWarnings(): Set { + return this.states.get(profileEmittedPluginBudgetWarningsKey); + } + + configure(options: ProfileServiceOptions): void { + this.optionsValue = { + emitStatusUpdated: options.emitStatusUpdated ?? this.optionsValue.emitStatusUpdated, + }; + } + + update(changed: ProfileUpdateData): void { + const { activeToolNames, ...configChanged } = changed; + if ( + changed.profileName !== undefined && + this.activeProfile?.name !== changed.profileName + ) { + this.activeProfile = undefined; + } + if (Object.keys(configChanged).length > 0) { + void this.dispatcher.dispatch(new ConfigUpdate(this.resolveConfigPayload(configChanged))); + this.afterConfigDispatch(configChanged); + } + if (activeToolNames !== undefined) { + this.setActiveTools(activeToolNames); + } + } + + applyBindingSnapshot(snapshot: ProfileBindingSnapshot): void { + this.activeProfile = undefined; + this.activeToolNamesOverlay = undefined; + const agentsMdPaths = + snapshot.agentsMdPaths ?? extractAgentsMdPathsFromSystemPrompt(snapshot.systemPrompt); + void this.dispatcher.dispatch( + new ProfileBind({ + agentId: this.scopeContext.agentId, + modelAlias: snapshot.modelAlias, + profileName: snapshot.profileName, + thinkingEffort: snapshot.thinkingLevel, + systemPrompt: snapshot.systemPrompt, + environmentDisclosure: snapshot.environmentDisclosure, + renderGeneration: snapshot.renderGeneration, + agentsMdPaths, + activeToolNames: snapshot.activeToolNames, + disallowedTools: snapshot.disallowedTools ?? [], + subagents: snapshot.subagents, + }), + ); + this.afterConfigDispatch({ + modelAlias: snapshot.modelAlias, + profileName: snapshot.profileName, + thinkingLevel: snapshot.thinkingLevel, + systemPrompt: snapshot.systemPrompt, + environmentDisclosure: snapshot.environmentDisclosure, + agentsMdPaths, + disallowedTools: snapshot.disallowedTools ?? [], + }); + this.agentsMdReminder.seedInjected(agentsMdPaths, this.sessionContext.cwd); + } + + async bind(input: BindAgentInput): Promise { + await this.catalog.ready; + await this.identity.resolved(); + this.assertBindable(input.profile); + const profile = this.catalog.get(input.profile); + if (profile === undefined) { + const available = this.catalog + .list() + .map((p) => p.name) + .join(', '); + throw new ProfileError( + ProfileErrors.codes.PROFILE_UNKNOWN, + `Unknown agent profile: "${input.profile}". Available profiles: ${available}`, + { profile: input.profile, available }, + ); + } + const alias = input.model ?? this.config.get('defaultModel'); + if (alias === undefined || alias === '') { + throw new ProfileError( + ProfileErrors.codes.MODEL_NOT_CONFIGURED, + `model is required to bind profile "${input.profile}" (no default model configured)`, + ); + } + const model = this.modelCatalog.get(alias); + + if (input.strictThinking === true && input.thinking !== undefined) { + this.assertThinkingEffortSupported(input.thinking, model, alias); + } + + await this.sessionToolPolicy.ready; + const context = await this.buildSystemPromptContext(profile); + this.assertBindable(profile.name); + const currentProfileName = this.profileName; + const rendered = renderAgentProfilePrompt(profile, context); + this.activeProfile = profile; + this.cacheAgentsMdWarning(context); + + const thinkingLevel = this.resolveThinkingEffort( + input.thinking ?? (currentProfileName !== undefined ? this.thinkingLevel : undefined), + model, + ); + + this.activeToolNamesOverlay = undefined; + await this.dispatcher.dispatch(new ProfileBind({ + agentId: this.scopeContext.agentId, + modelAlias: alias, + profileName: profile.name, + thinkingEffort: thinkingLevel, + systemPrompt: rendered.text, + environmentDisclosure: rendered.environment, + agentsMdPaths: context.agentsMdPaths ?? [], + activeToolNames: profile.tools, + disallowedTools: profile.disallowedTools ?? [], + subagents: profile.subagents, + })); + this.afterConfigDispatch({ + modelAlias: alias, + profileName: profile.name, + thinkingLevel, + systemPrompt: rendered.text, + disallowedTools: profile.disallowedTools ?? [], + }); + this.seedAgentsMdReminder(context); + + this.publishAgentsMdWarning(); + this.publishToolPatternWarnings(profile); + } + + async setModel(alias: string): Promise { + const model = this.modelCatalog.get(alias); + if (this.profileName === undefined) { + await this.bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: alias }); + this.telemetry.track2('model_switch', { model: alias }); + } else if (this.modelAlias !== alias) { + this.update({ modelAlias: alias }); + this.telemetry.track2('model_switch', { model: alias }); + } + return { + model: alias, + providerName: model.providerName, + }; + } + + setThinking(level: string): void { + const previousEffort = this.thinkingLevel; + this.assertThinkingEffortSupported(level, this.tryResolveRawModel(), this.modelAlias ?? ''); + const normalized = normalizeRequestedThinkingEffort(level); + this.update({ thinkingLevel: normalized ?? level }); + const effort = this.thinkingLevel; + if (effort !== previousEffort) { + this.telemetry.track2('thinking_toggle', { + enabled: effort !== 'off', + effort, + from: previousEffort, + }); + } + } + + private assertThinkingEffortSupported( + requested: string, + model: Model | undefined, + modelAlias: string, + ): void { + const normalized = normalizeRequestedThinkingEffort(requested); + if (normalized === undefined || this.supportsThinkingEffort(normalized, model)) return; + const efforts = model?.supportEfforts ?? []; + const supported = efforts.length === 0 ? 'off' : ['off', ...efforts].join(', '); + throw new ProfileError( + ProfileErrors.codes.MODEL_CONFIG_INVALID, + `Thinking effort "${requested}" is not supported by model "${modelAlias}". Supported efforts: ${supported}.`, + ); + } + + getModel(): string { + return this.modelAlias ?? ''; + } + + useProfile(profile: ResolvedAgentProfile, context: SystemPromptContext): void { + this.activeProfile = profile; + const rendered = renderAgentProfilePrompt(profile, context); + this.update({ + profileName: profile.name, + systemPrompt: rendered.text, + environmentDisclosure: rendered.environment, + agentsMdPaths: context.agentsMdPaths ?? [], + disallowedTools: profile.disallowedTools ?? [], + }); + this.setActiveTools(profile.tools); + } + + async applyProfile(profile: ResolvedAgentProfile, options?: ApplyProfileOptions): Promise { + const context = await this.buildSystemPromptContext(profile, options); + this.useProfile(profile, context); + this.seedAgentsMdReminder(context); + this.cacheAgentsMdWarning(context); + this.publishAgentsMdWarning(); + this.publishToolPatternWarnings(profile); + } + + private seedAgentsMdReminder(context: SystemPromptContext): void { + this.agentsMdReminder.seedInjected( + context.agentsMdPaths ?? [], + context.cwd ?? this.sessionContext.cwd, + ); + } + + getAgentsMdWarning(): string | undefined { + return this.agentsMdWarning; + } + + data(): ProfileData { + const model = this.tryResolveRawModel(); + return { + modelAlias: this.modelAlias, + modelCapabilities: model?.capabilities ?? UNKNOWN_CAPABILITY, + profileName: this.profileName, + thinkingLevel: this.thinkingLevel, + systemPrompt: this.systemPrompt, + agentsMdPaths: this.profileState.agentsMdPaths, + activeToolNames: this.activeToolNames === undefined ? undefined : [...this.activeToolNames], + disallowedTools: [...(this.profileState.disallowedTools ?? [])], + subagents: + this.profileState.subagents === undefined ? undefined : [...this.profileState.subagents], + environmentDisclosure: this.profileState.environmentDisclosure, + renderGeneration: this.profileState.renderGeneration, + }; + } + + getEffectiveThinkingLevel(): ThinkingEffort { + return this.resolveThinkingState(this.tryResolveRawModel()).effective; + } + + resolveModelContext(): ProfileModelContext { + const modelAlias = this.model; + const model = this.modelCatalog.get(modelAlias); + const loopControl = this.config.get('loopControl'); + return { + modelAlias, + modelCapabilities: model.capabilities, + maxOutputSize: model.maxOutputSize, + alwaysThinking: model.alwaysThinking || undefined, + thinkingLevel: this.resolveThinkingState(model).effective, + reservedContextSize: loopControl?.reservedContextSize, + compactionTriggerRatio: loopControl?.compactionTriggerRatio, + compactionMaxAttempts: loopControl?.compactionMaxAttempts, + }; + } + + resolveRequestParams(): ModelRequestParams { + const model = this.tryResolveRawModel(); + const thinking = this.resolveThinkingState(model); + const thinkingConfig = this.config.get(THINKING_SECTION); + const overrides = this.config.get('modelOverrides'); + const sampling: SamplingOptions = { + temperature: overrides?.temperature, + topP: overrides?.topP, + }; + return { + cacheKey: this.sessionContext.sessionId, + sampling: + sampling.temperature === undefined && sampling.topP === undefined ? undefined : sampling, + thinkingEffort: thinking.effective, + thinkingKeep: resolveThinkingKeep( + overrides?.thinkingKeep, + thinkingConfig?.keep, + thinking.effective, + ), + }; + } + + getModelCapabilities(): ModelCapability { + return this.tryResolveRawModel()?.capabilities ?? UNKNOWN_CAPABILITY; + } + + getModelProviderType(alias?: string): string | undefined { + const effective = alias ?? this.modelAlias ?? this.config.get('defaultModel'); + return this.resolveModelForThinking(effective)?.providerType; + } + + getMaxOutputSize(): number | undefined { + return this.tryResolveRawModel()?.maxOutputSize; + } + + hasModel(): boolean { + return this.modelAlias !== undefined; + } + + isRunnable(): boolean { + return this.profileName !== undefined && this.hasModel(); + } + + hasProvider(): boolean { + return this.tryResolveRawModel() !== undefined; + } + + getSystemPrompt(): string { + return this.systemPrompt; + } + + getActiveToolNames(): readonly string[] | undefined { + return this.activeToolNames; + } + + addActiveTool(name: string): void { + const activeToolNames = this.activeToolNames; + if (activeToolNames === undefined || activeToolNames.includes(name)) return; + this.activeToolNamesOverlay = [...activeToolNames, name]; + } + + removeActiveTool(name: string): void { + const activeToolNames = this.activeToolNames; + if (activeToolNames === undefined || !activeToolNames.includes(name)) return; + this.activeToolNamesOverlay = activeToolNames.filter((candidate) => candidate !== name); + } + + private resolveConfigPayload( + changed: Omit, + ): ConfigUpdatePayload { + const payload: ConfigUpdatePayload = { agentId: this.scopeContext.agentId }; + if (changed.modelAlias !== undefined) payload.modelAlias = changed.modelAlias; + if (changed.profileName !== undefined) payload.profileName = changed.profileName; + if (changed.thinkingLevel !== undefined || changed.modelAlias !== undefined) { + const model = this.resolveModelForThinking(changed.modelAlias ?? this.modelAlias); + const requested = + changed.thinkingLevel ?? (this.modelAlias === undefined ? undefined : this.thinkingLevel); + payload.thinkingEffort = this.resolveThinkingEffort(requested, model); + } + if (changed.systemPrompt !== undefined) { + payload.systemPrompt = changed.systemPrompt; + if (changed.environmentDisclosure !== undefined) { + payload.environmentDisclosure = changed.environmentDisclosure; + } + } + if (changed.agentsMdPaths !== undefined) { + payload.agentsMdPaths = [...changed.agentsMdPaths]; + } + if (changed.disallowedTools !== undefined) { + payload.disallowedTools = [...changed.disallowedTools]; + } + return payload; + } + + private afterConfigDispatch(changed: Omit): void { + if (changed.modelAlias !== undefined) { + this.syncTelemetryModelContext(changed.modelAlias); + } + if (changed.modelAlias !== undefined || changed.thinkingLevel !== undefined) { + this.warnAboutAnthropicThinkingEffort(); + } + this.emitStatusUpdated( + changed.modelAlias !== undefined || changed.thinkingLevel !== undefined, + ); + } + + private syncTelemetryModelContext(modelAlias: string | undefined): void { + if (modelAlias === undefined) { + return; + } + const model = this.tryResolveRawModel(); + this.telemetry.setContext({ + model: modelAlias, + provider_type: model?.providerType ?? model?.protocol, + protocol: model?.protocol, + }); + } + + private warnAboutAnthropicThinkingEffort(): void { + try { + const model = this.tryResolveRawModel(); + if (model?.protocol !== 'anthropic') return; + const effort = this.getEffectiveThinkingLevel(); + if (effort === 'on' || effort === 'off') return; + + let code: string; + let message: string; + let knownEfforts = ''; + const efforts = model.supportEfforts?.filter((value) => value.length > 0); + if (efforts === undefined || efforts.length === 0 || efforts.includes(effort)) return; + knownEfforts = efforts.join(','); + code = 'anthropic-thinking-effort-not-listed'; + message = `Thinking effort "${effort}" is not listed for model "${model.name}" (known: ${efforts.join(', ')}). The configured value will be sent unchanged to the Anthropic-compatible backend.`; + + const key = [code, model.id, model.name, effort, knownEfforts].join('\u0000'); + if (this.emittedThinkingEffortWarnings.has(key)) return; + this.emittedThinkingEffortWarnings.add(key); + void this.dispatcher.dispatch(new WarningIssued({ agentId: this.scopeContext.agentId, code, message })); + } catch { + } + } + + private setActiveTools(names: readonly string[] | undefined): void { + this.activeToolNamesOverlay = undefined; + if (names === undefined) { + void this.dispatcher.dispatch(new ToolsResetActiveTools({ agentId: this.scopeContext.agentId })); + return; + } + void this.dispatcher.dispatch( + new ToolsSetActiveTools({ agentId: this.scopeContext.agentId, names: [...names] }), + ); + } + + private emitStatusUpdated(includeThinkingEffort = false): void { + const custom = this.optionsValue.emitStatusUpdated; + if (custom !== undefined) { + custom(); + return; + } + const modelAlias = this.modelAlias; + if (modelAlias === undefined) return; + const capabilities = this.tryResolveRawModel()?.capabilities; + const maxContextTokens = capabilities?.max_input_tokens ?? capabilities?.max_context_tokens; + void this.dispatcher.dispatch( + new AgentStatusUpdated({ + agentId: this.scopeContext.agentId, + model: modelAlias, + thinkingEffort: includeThinkingEffort + ? this.getEffectiveThinkingLevel() + : undefined, + maxContextTokens: + maxContextTokens !== undefined && maxContextTokens > 0 ? maxContextTokens : undefined, + }), + ); + } + + republishStatus(): void { + this.emitStatusUpdated(true); + } + + private get profileState(): ProfileModelState { + return this.states.get(profileKey); + } + + private get model(): string { + const modelAlias = this.modelAlias; + if (modelAlias === undefined) { + throw new Error2(ErrorCodes.MODEL_NOT_CONFIGURED, 'Model not set'); + } + return modelAlias; + } + + private get modelAlias(): string | undefined { + return this.profileState.modelAlias; + } + + private get profileName(): string | undefined { + return this.profileState.profileName; + } + + private get systemPrompt(): string { + return this.profileState.systemPrompt; + } + + private get thinkingLevel(): ThinkingEffort { + const stored = this.profileState.thinkingLevel; + if (stored === 'off' && this.alwaysThinkingModel) { + return this.resolveThinkingEffort(stored, this.tryResolveRawModel()); + } + return stored; + } + + private resolveThinkingState(model: Model | undefined): { + readonly effective: ThinkingEffort; + readonly forced: ThinkingEffort | undefined; + } { + const base = this.thinkingLevel; + const forced = resolveForcedThinkingEffort( + this.config.get(THINKING_SECTION)?.forcedEffort, + base, + drivesThinkingThroughTraits(model?.providerType), + ); + return { effective: forced ?? base, forced }; + } + + private strictThinkingValidation(model: Model | undefined): boolean { + if (model === undefined) return false; + return requiresStrictThinkingValidation( + this.protocolAdapters, + model.protocol, + model.providerType, + ); + } + + private resolveThinkingEffort( + requested: string | undefined, + model: Model | undefined, + ): ThinkingEffort { + return resolveThinkingEffortForModel( + requested, + this.config.get(THINKING_SECTION), + model, + this.strictThinkingValidation(model), + ); + } + + private supportsThinkingEffort(effort: ThinkingEffort, model: Model | undefined): boolean { + return modelSupportsThinkingEffort(effort, model, this.strictThinkingValidation(model)); + } + + private get alwaysThinkingModel(): boolean { + return this.tryResolveRawModel()?.alwaysThinking === true; + } + + private tryResolveRawModel(): Model | undefined { + const alias = this.modelAlias; + return this.resolveModelForThinking(alias); + } + + private resolveModelForThinking(alias: string | undefined): Model | undefined { + if (alias === undefined) return undefined; + try { + return this.modelCatalog.get(alias); + } catch { + return undefined; + } + } + + private assertBindable(requested: string): void { + const current = this.profileName; + if (current !== undefined && current !== requested) { + throw new ProfileError( + ProfileErrors.codes.PROFILE_ALREADY_BOUND, + `agent is already bound to profile "${current}"; cannot switch to "${requested}" in this session`, + { current, requested }, + ); + } + } + + private cacheAgentsMdWarning(context: Pick): void { + this.agentsMdWarning = context.agentsMdWarning; + } + + private publishAgentsMdWarning(): void { + const warning = this.agentsMdWarning; + if (warning === undefined) return; + void this.dispatcher.dispatch( + new WarningIssued({ + agentId: this.scopeContext.agentId, + message: warning, + code: 'agents-md-oversized', + }), + ); + } + + private publishToolPatternWarnings(profile?: ResolvedAgentProfile): void { + const known = new Set(); + for (const contribution of getAgentToolContributions()) known.add(contribution.options.name); + for (const ref of this.toolRegistry.listReferences()) known.add(ref.name); + for (const builtin of this.builtinProfiles.list()) { + for (const name of literalToolNames([ + ...(builtin.tools ?? []), + ...(builtin.disallowedTools ?? []), + ])) { + known.add(name); + } + } + const checks: { + context: string; + field: string; + patterns: readonly string[] | undefined; + }[] = []; + if (profile !== undefined) { + checks.push( + { context: `profile "${profile.name}"`, field: 'tools', patterns: profile.tools }, + { + context: `profile "${profile.name}"`, + field: 'disallowedTools', + patterns: profile.disallowedTools, + }, + ); + } + const global = this.config.get(TOOLS_SECTION); + checks.push( + { context: 'the global [tools] config', field: 'enabled', patterns: global?.enabled }, + { context: 'the global [tools] config', field: 'disabled', patterns: global?.disabled }, + ); + for (const { context, field, patterns } of checks) { + if (patterns === undefined) continue; + for (const issue of findInactiveToolPatterns(patterns, (name) => known.has(name))) { + const key = `${context}|${field}|${issue.pattern}`; + if (this.emittedToolPatternWarnings.has(key)) continue; + this.emittedToolPatternWarnings.add(key); + void this.dispatcher.dispatch( + new WarningIssued({ + agentId: this.scopeContext.agentId, + code: 'tool-pattern-no-match', + message: describeInactiveToolPattern(context, field, issue), + }), + ); + } + } + } + + private async buildSystemPromptContext( + profile: ResolvedAgentProfile, + options?: ApplyProfileOptions, + ): Promise { + await this.notify.ready; + const preloadedAgentsMd = await this.workspaceInstructionsSnapshot(); + const fsAvailable = this.runtime.isAvailable(['fs']); + const lease = this.runtime.acquire(fsAvailable ? ['fs'] : []); + const env = lease.runtime.environment; + const view = new RuntimeWorkspaceView(lease.runtime, { + workDir: this.sessionContext.cwd, + additionalDirs: options?.additionalDirs ?? this.workspace.additionalDirs, + }); + let base: SystemPromptContext; + try { + base = !fsAvailable + ? {} + : await prepareSystemPromptContext( + { fs: lease.runtime.fs!, homeDir: env.homeDir }, + view.workDir, + this.bootstrap.homeDir, + { + additionalDirs: view.additionalDirs, + preloadedAgentsMd, + }, + ); + } finally { + lease.dispose(); + } + const skills = await this.resolveSkillListing(); + const pluginSections = await this.resolvePluginSections(); + return { + ...base, + cwd: view.workDir, + osKind: env.osKind, + shellName: env.shellName, + shellPath: env.shellPath, + skills, + pluginSections, + skillActive: this.isToolActiveForProfile(profile, 'Skill'), + productName: (await this.identity.resolved()).displayName, + replyStyleGuide: this.bootstrap.args.replyStyleGuide, + notifyUserActive: + this.notify.enabled && + this.isToolActiveForProfile(profile, NOTIFY_USER_TOOL_NAME), + }; + } + + private async workspaceInstructionsSnapshot(): Promise { + await this.instructions.ready; + return { + content: this.instructions.agentsMd ?? '', + warning: this.instructions.agentsMdWarning, + paths: this.instructions.agentsMdPaths ?? [], + }; + } + + private isToolActiveForProfile( + profile: ResolvedAgentProfile, + name: string, + source: ToolSource = 'builtin', + ): boolean { + return isToolActiveComposed( + { + workspaceDisabledTools: this.toolPolicyGate.disabledTools, + profile, + global: this.config.get(TOOLS_SECTION), + sessionDisabledTools: this.sessionToolPolicy.disabledTools(), + }, + name, + source, + ); + } + + private async resolveSkillListing(): Promise { + if (this.frozenSkillListing !== undefined) return this.frozenSkillListing; + try { + await this.skillCatalog.ready; + const listing = this.skillCatalog.catalog.getModelSkillListing(); + this.frozenSkillListing = listing; + return listing; + } catch { + return ''; + } + } + + private async resolvePluginSections(): Promise { + if (this.frozenPluginSections !== undefined) return this.frozenPluginSections; + const sections = await this.plugins.enabledSystemPrompts(); + const parts: string[] = []; + const skipped: string[] = []; + let totalBytes = 0; + for (const section of sections) { + const block = `\n${section.content}`; + const bytes = Buffer.byteLength(block, 'utf8'); + if (totalBytes + bytes > PLUGIN_SECTIONS_MAX_BYTES) { + skipped.push(section.pluginId); + continue; + } + totalBytes += bytes; + parts.push(block); + } + if (skipped.length > 0) { + const newlySkipped = skipped.filter((id) => !this.emittedPluginBudgetWarnings.has(id)); + if (newlySkipped.length > 0) { + for (const id of newlySkipped) this.emittedPluginBudgetWarnings.add(id); + void this.dispatcher.dispatch( + new WarningIssued({ + agentId: this.scopeContext.agentId, + message: + `Plugin system-prompt contributions from ${newlySkipped.map((id) => `"${id}"`).join(', ')} ` + + `were skipped: the aggregate ${PLUGIN_SECTIONS_MAX_BYTES / 1024} KB budget is exhausted.`, + code: 'plugin-sections-oversized', + }), + ); + } + } + const resolved = parts.join('\n\n'); + if (this.plugins.hasLoadedSnapshot()) this.frozenPluginSections = resolved; + return resolved; + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentProfileService, + AgentProfileService, + ScopeActivation.OnScopeCreated, + 'profile', +); diff --git a/packages/agent-core-v2/src/agent/prompt/errors.ts b/packages/agent-core-v2/src/agent/prompt/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..12c804c92ed9d12fcfa2bd987f04cfe90be261b8 --- /dev/null +++ b/packages/agent-core-v2/src/agent/prompt/errors.ts @@ -0,0 +1,14 @@ +import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; + +export const PromptErrors = { + codes: { + REQUEST_INVALID: 'request.invalid', + REQUEST_WORK_DIR_REQUIRED: 'request.work_dir_required', + REQUEST_PROMPT_INPUT_EMPTY: 'request.prompt_input_empty', + PROMPT_ID_CONFLICT: 'prompt.id_conflict', + PROMPT_NOT_FOUND: 'prompt.not_found', + SESSION_BUSY: 'session.busy', + }, +} as const satisfies ErrorDomain; + +registerErrorDomain(PromptErrors); diff --git a/packages/agent-core-v2/src/agent/prompt/messageContent.ts b/packages/agent-core-v2/src/agent/prompt/messageContent.ts new file mode 100644 index 0000000000000000000000000000000000000000..0dd9b7c66cd30895f211f43080881ab38219d43e --- /dev/null +++ b/packages/agent-core-v2/src/agent/prompt/messageContent.ts @@ -0,0 +1,71 @@ +export type MessageRole = 'user' | 'assistant' | 'tool' | 'system'; + +export interface TextContent { + type: 'text'; + text: string; +} + +export interface ToolUseContent { + type: 'tool_use'; + tool_call_id: string; + tool_name: string; + input: unknown; +} + +export interface ToolResultContent { + type: 'tool_result'; + tool_call_id: string; + output: unknown; + is_error?: boolean; +} + +export type ImageSource = + | { + kind: 'url'; + url: string; + id?: string; + } + | { + kind: 'base64'; + media_type: string; + data: string; + } + | { kind: 'file'; file_id: string } + | { kind: 'session_media'; file_id: string } + | { kind: 'path'; path: string }; + +export interface ImageContent { + type: 'image'; + source: ImageSource; + name?: string; +} + +export interface VideoContent { + type: 'video'; + source: ImageSource; + name?: string; +} + +export interface FileContent { + type: 'file'; + file_id?: string; + path?: string; + name?: string; + media_type?: string; + size?: number; +} + +export interface ThinkingContent { + type: 'thinking'; + thinking: string; + signature?: string; +} + +export type MessageContent = + | TextContent + | ToolUseContent + | ToolResultContent + | ImageContent + | VideoContent + | FileContent + | ThinkingContent; diff --git a/packages/agent-core-v2/src/agent/prompt/promptEvents.ts b/packages/agent-core-v2/src/agent/prompt/promptEvents.ts new file mode 100644 index 0000000000000000000000000000000000000000..8caee57df103e17e26ac7e6f8c8d9ec72e7d820e --- /dev/null +++ b/packages/agent-core-v2/src/agent/prompt/promptEvents.ts @@ -0,0 +1,146 @@ +import type { UserPromptOrigin } from '#/agent/contextMemory/types'; +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { z } from 'zod'; + +import { AgentEvent2, registerEvent2Class } from '#/app/event/event2'; +import type { ContentPart } from '#human/llm/message'; +import type { MessageContent } from '#/agent/prompt/messageContent'; + +export interface PromptCompletedPayload { + readonly agentId: string; + readonly promptId: string; + readonly finishedAt: string; + readonly reason: 'completed' | 'failed' | 'blocked'; +} + +const promptCompletedSchema = z.object({ + agentId: z.string(), + promptId: z.string().min(1), + finishedAt: z.string(), + reason: z.union([z.literal('completed'), z.literal('failed'), z.literal('blocked')]), +}); + +export class PromptCompleted extends AgentEvent2> { + static override readonly type = 'prompt.completed'; + static override readonly durable = true; + static override readonly observable = true; + static override readonly schema = promptCompletedSchema; +} +export interface PromptCompleted extends PromptCompletedPayload {} + +export interface PromptCompletedEvent { + readonly type: 'prompt.completed'; + readonly promptId: string; + readonly finishedAt: string; + readonly reason?: 'completed' | 'failed' | 'blocked'; +} + +export interface PromptAbortedPayload { + readonly agentId: string; + readonly promptId: string; + readonly abortedAt: string; +} + +const promptAbortedSchema = z.object({ + agentId: z.string(), + promptId: z.string().min(1), + abortedAt: z.string(), +}); + +export class PromptAborted extends AgentEvent2> { + static override readonly type = 'prompt.aborted'; + static override readonly durable = true; + static override readonly observable = true; + static override readonly schema = promptAbortedSchema; +} +export interface PromptAborted extends PromptAbortedPayload {} + +export interface PromptAbortedEvent extends Omit { + readonly type: 'prompt.aborted'; +} + +export interface PromptSubmittedEvent { + readonly type: 'prompt.submitted'; + readonly promptId: string; + readonly userMessageId: string; + readonly status: 'running' | 'queued' | 'blocked'; + readonly content: readonly MessageContent[]; + readonly createdAt: string; +} + +export interface PromptSteeredEvent { + readonly type: 'prompt.steered'; + readonly activePromptId: string; + readonly promptIds: readonly string[]; + readonly content: readonly MessageContent[]; + readonly steeredAt: string; +} + +export interface PromptSteeredPayload { + readonly agentId: string; + readonly activePromptId: string; + readonly promptIds: string[]; + readonly content: ContentPart[]; + readonly steeredAt: string; +} + +const promptSteeredSchema = z.object({ + agentId: z.string(), + activePromptId: z.string(), + promptIds: z.array(z.string()), + content: z.custom(), + steeredAt: z.string(), +}); + +export class PromptSteered extends AgentEvent2> { + static override readonly type = 'prompt.steered'; + static override readonly durable = true; + static override readonly observable = true; + static override readonly schema = promptSteeredSchema; +} +export interface PromptSteered extends PromptSteeredPayload {} + +export interface PromptQueuedPayload { + readonly agentId: string; + readonly promptId: string; + readonly content: ContentPart[]; + readonly queueLength: number; + readonly clientMetadata?: UserPromptOrigin['clientMetadata']; +} + +export class PromptQueued extends AgentEvent2 { + static override readonly type = 'prompt.queued'; + static override readonly observable = true; +} +export interface PromptQueued extends PromptQueuedPayload {} + +export interface PromptSubmittedPayload { + readonly agentId: string; + readonly promptId: string; + readonly userMessageId: string; + readonly status: 'running' | 'queued'; + readonly content: ContentPart[]; + readonly createdAt: string; + readonly clientMetadata?: UserPromptOrigin['clientMetadata']; +} + +export class PromptSubmitted extends AgentEvent2 { + static override readonly type = 'prompt.submitted'; + static override readonly observable = true; +} +export interface PromptSubmitted extends PromptSubmittedPayload {} + +export interface PromptStartedPayload { + readonly agentId: string; + readonly promptId: string; +} + +export class PromptStarted extends AgentEvent2 { + static override readonly type = 'prompt.started'; + static override readonly observable = true; +} +export interface PromptStarted extends PromptStartedPayload {} + +registerEvent2Class(PromptCompleted); +registerEvent2Class(PromptAborted); +registerEvent2Class(PromptSteered); diff --git a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts new file mode 100644 index 0000000000000000000000000000000000000000..79e157dd39581d9c0519004828c875db938e589b --- /dev/null +++ b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts @@ -0,0 +1,73 @@ +import type { ContentPart } from '#human/llm/message'; +import { matchSingleMediaPathTag } from '#/agent/media/mediaRef'; +import { extractImageCompressionCaptions } from '#/agent/media/image-compress'; + +const MAX_TITLE_LENGTH = 200; +const MAX_LAST_PROMPT_LENGTH = 4000; + +export function titleFromPromptMetadataText(text: string): string { + return text.slice(0, MAX_TITLE_LENGTH); +} + +export function promptMetadataTextFromContentParts( + parts: readonly ContentPart[], + clientMetadata?: unknown, +): string | undefined { + if (Array.isArray(clientMetadata) && clientMetadata.length > 0) { + const displayTexts = clientMetadata.map((entry: unknown) => { + if (typeof entry !== 'object' || entry === null) return undefined; + const text = (entry as { display_text?: unknown }).display_text; + return typeof text === 'string' ? text : undefined; + }); + if (displayTexts.every((text) => text !== undefined)) return promptMetadataTextFromText(displayTexts.join('\n')); + } + return promptMetadataTextFromText(promptDisplayTextFromContentParts(parts)); +} + +export function promptDisplayTextFromContentParts(parts: readonly ContentPart[]): string { + const texts: string[] = []; + for (const part of parts) { + const text = promptPartText(part); + if (text !== undefined) texts.push(text); + } + return texts.join('\n'); +} + +export function promptMetadataTextFromText(text: string): string | undefined { + const sanitized = text + .replaceAll( + /-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/gi, + '[redacted]', + ) + .replaceAll(/\b(authorization)\s*:\s*bearer\s+\S+/gi, '$1: Bearer [redacted]') + .replaceAll( + /\b(api[_-]?key|token|secret|password|passwd|pwd)\b\s*[:=]\s*(?:"[^"]*"|'[^']*'|\S+)/gi, + '$1=[redacted]', + ) + .replaceAll(/\bsk-[A-Za-z0-9_-]{12,}\b/g, '[redacted]') + .replaceAll(/\b[A-Za-z0-9][A-Za-z0-9+/=_-]{39,}\b/g, '[redacted]') + .replaceAll(/\p{Cc}+/gu, ' ') + .replaceAll(/\s+/g, ' ') + .trim(); + + if (sanitized.length === 0) return undefined; + return sanitized.slice(0, MAX_LAST_PROMPT_LENGTH); +} + +function promptPartText(part: ContentPart): string | undefined { + switch (part.type) { + case 'text': { + if (matchSingleMediaPathTag(part.text) !== undefined) return undefined; + const { text } = extractImageCompressionCaptions(part.text); + return text.trim().length === 0 ? undefined : text; + } + case 'image_url': + return '[image]'; + case 'audio_url': + return '[audio]'; + case 'video_url': + return '[video]'; + case 'think': + return undefined; + } +} diff --git a/packages/agent-core-v2/src/agent/replayBuilder/fold.ts b/packages/agent-core-v2/src/agent/replayBuilder/fold.ts new file mode 100644 index 0000000000000000000000000000000000000000..ee3ebca7bfdb1663b9e767d74f7ed267532fc490 --- /dev/null +++ b/packages/agent-core-v2/src/agent/replayBuilder/fold.ts @@ -0,0 +1,537 @@ +import type { LoopRecordedEvent } from '#/agent/contextMemory/loopEventFold'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import type { CompactionResult } from '#/agent/fullCompaction/types'; +import type { PermissionApprovalResultRecord } from '#/agent/permissionRules/permissionRules'; +import type { PermissionMode } from '#/agent/permissionPolicy/types'; +import type { AgentConfigUpdateData } from '#/agent/profile/profile'; +import type { + GoalActor, + GoalBudgetLimits, + GoalBudgetReport, + GoalSnapshot, + GoalStatus, +} from '#/features/goal/types'; +import { createToolMessage } from '#/llm-adapter/contract/message'; +import { estimateTokens, estimateTokensForMessages } from '#/llm-adapter/contract/tokens'; +import { + isNewerWireVersion, + migrateV1_4ToV1_5, + migrateWireRecord, + resolveWireMigrations, + type WireMigration, +} from '#/wire/migration/migration'; +import { isWireMetadataRecord, type WireRecord } from '#/wire/record'; + +import type { AgentReplayRecord, AgentReplayRecordPayload } from './types'; + +export interface WireReplayFold { + readonly replay: readonly AgentReplayRecord[]; + readonly toolStore: Readonly>; +} + +const TOOL_INTERRUPTED_ON_RESUME_OUTPUT = + 'Tool execution was interrupted before its result was recorded. Do not assume the tool completed successfully.'; + +const COMPACT_USER_MESSAGE_MAX_TOKENS = 20_000; + +const GOAL_FORK_CLEARED_REMINDER = + 'This fork does not have a current goal. Ignore earlier active-goal reminders from the source session. Handle requests normally unless the user starts a new goal.'; + +export function foldWireRecords(records: readonly WireRecord[]): WireReplayFold { + const fold = new WireReplayFoldState(); + for (const record of migrateJournalRecords(records)) { + fold.apply(record); + } + fold.finish(); + return { replay: fold.replay, toolStore: fold.toolStore }; +} + +function migrateJournalRecords(records: readonly WireRecord[]): WireRecord[] { + if (records.length === 0) return []; + const first = records[0]!; + let migrations: readonly WireMigration[]; + if (first.type === 'metadata') { + if (!isWireMetadataRecord(first)) { + throw new Error('Agent wire metadata is malformed'); + } + migrations = isNewerWireVersion(first.protocol_version) + ? [] + : resolveWireMigrations(first.protocol_version); + } else { + migrations = [migrateV1_4ToV1_5]; + } + return records.map((record) => migrateWireRecord(record, migrations)); +} + +interface FoldGoalState { + goalId: string; + objective: string; + completionCriterion?: string; + status: GoalStatus; + turnsUsed: number; + tokensUsed: number; + wallClockMs: number; + budgetLimits: GoalBudgetLimits; + terminalReason?: string; +} + +class WireReplayFoldState { + readonly replay: AgentReplayRecord[] = []; + readonly toolStore: Record = {}; + private history: ContextMessage[] = []; + private readonly openSteps = new Map(); + private readonly pendingToolResultIds = new Set(); + private deferredMessages: ContextMessage[] = []; + private goal: FoldGoalState | undefined; + + apply(record: WireRecord): void { + const time = record.time ?? Date.now(); + switch (record.type) { + case 'context.append_message': + this.appendMessage(record['message'] as ContextMessage, time); + return; + case 'context.append_loop_event': + this.appendLoopEvent(record['event'] as LoopRecordedEvent, time); + return; + case 'context.undo': + this.undo(record['count'] as number); + return; + case 'context.clear': + this.clearContext(); + return; + case 'context.apply_compaction': + this.applyCompaction(record); + return; + case 'full_compaction.begin': + this.push( + { type: 'compaction', instruction: readString(record, 'instruction') }, + time, + ); + return; + case 'full_compaction.cancel': + this.patchLastCompaction({ result: 'cancelled' }); + return; + case 'goal.create': + this.createGoal(record, time); + return; + case 'goal.update': + this.updateGoal(record, time); + return; + case 'goal.clear': + this.goal = undefined; + return; + case 'forked': + this.applyForked(time); + return; + case 'plan_mode.enter': + this.push({ type: 'plan_updated', enabled: true }, time); + return; + case 'plan_mode.cancel': + case 'plan_mode.exit': + this.push({ type: 'plan_updated', enabled: false }, time); + return; + case 'config.update': + this.updateConfig(record, time); + return; + case 'permission.set_mode': + this.push({ type: 'permission_updated', mode: record['mode'] as PermissionMode }, time); + return; + case 'permission.record_approval_result': + this.recordApprovalResult(record, time); + return; + case 'tools.update_store': + this.toolStore[record['key'] as string] = record['value']; + return; + default: + return; + } + } + + finish(): void { + this.closePendingToolResults(Date.now()); + } + + private push(payload: AgentReplayRecordPayload, time: number): void { + this.replay.push({ ...payload, time }); + } + + private pushHistory(messages: readonly ContextMessage[], time: number): void { + for (const message of messages) { + this.history.push(message); + this.push({ type: 'message', message }, time); + } + } + + private appendMessage(message: ContextMessage, time: number): void { + if (this.pendingToolResultIds.size > 0) { + this.deferredMessages.push(message); + return; + } + this.pushHistory([message], time); + } + + private flushDeferredMessages(time: number): void { + if (this.pendingToolResultIds.size > 0 || this.deferredMessages.length === 0) { + return; + } + this.pushHistory(this.deferredMessages, time); + this.deferredMessages = []; + } + + private closePendingToolResults(time: number): void { + if (this.pendingToolResultIds.size === 0) return; + const messages: ContextMessage[] = []; + for (const toolCallId of this.pendingToolResultIds) { + messages.push({ + ...createToolMessage(toolCallId, TOOL_INTERRUPTED_ON_RESUME_OUTPUT), + isError: true, + }); + } + this.pendingToolResultIds.clear(); + this.pushHistory(messages, time); + this.flushDeferredMessages(time); + } + + private appendLoopEvent(event: LoopRecordedEvent, time: number): void { + switch (event.type) { + case 'step.begin': { + this.closePendingToolResults(time); + const message: ContextMessage = { role: 'assistant', content: [], toolCalls: [] }; + this.pushHistory([message], time); + this.openSteps.set(event.uuid, message); + return; + } + case 'step.end': { + this.openSteps.delete(event.uuid); + this.flushDeferredMessages(time); + return; + } + case 'content.part': { + const openStep = this.openSteps.get(event.stepUuid); + if (openStep === undefined) { + throw new Error( + `Received content_part for unknown step_uuid '${event.stepUuid}' (no open step_begin)`, + ); + } + openStep.content.push(event.part); + return; + } + case 'tool.call': { + const openStep = this.openSteps.get(event.stepUuid); + if (openStep === undefined) { + throw new Error( + `Received tool_call for unknown step_uuid '${event.stepUuid}' (no open step_begin)`, + ); + } + openStep.toolCalls.push({ + type: 'function', + id: event.toolCallId, + name: event.name, + arguments: event.args === undefined ? null : JSON.stringify(event.args), + extras: event.extras, + }); + if (event.display !== undefined) { + openStep.toolCallDisplays ??= {}; + openStep.toolCallDisplays[event.toolCallId] = event.display; + } + this.pendingToolResultIds.add(event.toolCallId); + return; + } + case 'tool.result': { + if (!this.pendingToolResultIds.has(event.toolCallId)) return; + const output = event.result.output; + this.pushHistory( + [ + { + ...createToolMessage( + event.toolCallId, + typeof output === 'string' ? output : [...output], + ), + isError: event.result.isError, + note: event.result.note, + }, + ], + time, + ); + this.pendingToolResultIds.delete(event.toolCallId); + this.flushDeferredMessages(time); + return; + } + } + } + + private undo(count: number): void { + if (count <= 0 || this.history.length === 0) return; + const removed = new Set(); + let removedUserCount = 0; + for (let i = this.history.length - 1; i >= 0; i--) { + const message = this.history[i]!; + if (message.origin?.kind === 'injection') continue; + if (message.origin?.kind === 'compaction_summary') break; + removed.add(message); + this.history.splice(i, 1); + if (isRealUserInput(message)) { + removedUserCount++; + if (removedUserCount >= count) break; + } + } + for (let i = this.replay.length - 1; i >= 0; i--) { + const record = this.replay[i]!; + if (record.type === 'message' && removed.has(record.message)) { + this.replay.splice(i, 1); + } + } + this.openSteps.clear(); + this.pendingToolResultIds.clear(); + this.deferredMessages = []; + } + + private clearContext(): void { + this.history = []; + this.openSteps.clear(); + this.pendingToolResultIds.clear(); + this.deferredMessages = []; + } + + private applyCompaction(record: WireRecord): void { + const summary = readCompactionSummary(record); + const contextSummary = readString(record, 'contextSummary') ?? summary; + const compactedCount = readNumber(record, 'compactedCount') ?? readNumber(record, 'count') ?? 0; + const keptTail = this.selectLegacyKeptTail(record); + const result: CompactionResult = { + summary, + contextSummary, + compactedCount, + tokensBefore: readNumber(record, 'tokensBefore') ?? 0, + tokensAfter: + readNumber(record, 'tokensAfter') ?? + estimateTokens(contextSummary) + estimateTokensForMessages(keptTail), + keptUserMessageCount: readNumber(record, 'keptUserMessageCount') ?? keptTail.length, + keptHeadUserMessageCount: readNumber(record, 'keptHeadUserMessageCount'), + droppedCount: readNumber(record, 'droppedCount'), + }; + this.patchLastCompaction({ result }); + const summaryMessage: ContextMessage = { + role: 'user', + content: [{ type: 'text', text: contextSummary }], + toolCalls: [], + origin: { kind: 'compaction_summary' }, + }; + this.history = + readNumber(record, 'keptUserMessageCount') === undefined && + compactedCount < this.history.length + ? [summaryMessage, ...this.history.slice(compactedCount)] + : [summaryMessage]; + this.openSteps.clear(); + this.pendingToolResultIds.clear(); + this.deferredMessages = []; + } + + private patchLastCompaction(patch: { readonly result: CompactionResult | 'cancelled' }): void { + const last = this.replay.at(-1); + if (last?.type === 'compaction') { + Object.assign(last, patch); + } + } + + private selectLegacyKeptTail(record: WireRecord): ContextMessage[] { + if ( + readNumber(record, 'tokensAfter') !== undefined && + readNumber(record, 'keptUserMessageCount') !== undefined + ) { + return []; + } + const compactable = this.history.filter((message) => isRealUserInput(message)); + const selected: ContextMessage[] = []; + let remaining = COMPACT_USER_MESSAGE_MAX_TOKENS; + for (let i = compactable.length - 1; i >= 0 && remaining > 0; i--) { + const message = compactable[i]!; + const tokens = estimateTokensForMessages([message]); + selected.unshift(message); + if (tokens > remaining) break; + remaining -= tokens; + } + return selected; + } + + private createGoal(record: WireRecord, time: number): void { + const state: FoldGoalState = { + goalId: record['goalId'] as string, + objective: record['objective'] as string, + completionCriterion: readString(record, 'completionCriterion'), + status: 'active', + turnsUsed: 0, + tokensUsed: 0, + wallClockMs: 0, + budgetLimits: {}, + }; + this.goal = state; + this.push( + { type: 'goal_updated', snapshot: goalSnapshot(state), change: { kind: 'created' } }, + time, + ); + } + + private updateGoal(record: WireRecord, time: number): void { + const state = this.goal; + if (state === undefined) return; + const status = record['status'] as GoalStatus | undefined; + const reason = readString(record, 'reason'); + if (status !== undefined) { + state.status = status; + state.terminalReason = status === 'active' ? undefined : reason; + } + const turnsUsed = readNumber(record, 'turnsUsed'); + if (turnsUsed !== undefined) state.turnsUsed = turnsUsed; + const tokensUsed = readNumber(record, 'tokensUsed'); + if (tokensUsed !== undefined) state.tokensUsed = tokensUsed; + const wallClockMs = readNumber(record, 'wallClockMs'); + if (wallClockMs !== undefined) state.wallClockMs = wallClockMs; + const budgetLimits = record['budgetLimits'] as GoalBudgetLimits | undefined; + if (budgetLimits !== undefined) state.budgetLimits = budgetLimits; + if (status === undefined) return; + const actor = record['actor'] as GoalActor | undefined; + this.push( + { + type: 'goal_updated', + snapshot: goalSnapshot(state), + change: + status === 'complete' + ? { + kind: 'completion', + status, + reason, + stats: { + turnsUsed: state.turnsUsed, + tokensUsed: state.tokensUsed, + wallClockMs: state.wallClockMs, + }, + actor, + } + : { kind: 'lifecycle', status, reason, actor }, + }, + time, + ); + } + + private applyForked(time: number): void { + if (this.goal === undefined) return; + this.goal = undefined; + this.appendMessage( + { + role: 'user', + content: [ + { type: 'text', text: `\n${GOAL_FORK_CLEARED_REMINDER}\n` }, + ], + toolCalls: [], + origin: { kind: 'system_trigger', name: 'goal_fork_cleared' }, + }, + time, + ); + } + + private updateConfig(record: WireRecord, time: number): void { + const config: AgentConfigUpdateData = { + modelAlias: readString(record, 'modelAlias'), + profileName: readString(record, 'profileName'), + thinkingLevel: readString(record, 'thinkingEffort') ?? readString(record, 'thinkingLevel'), + systemPrompt: readString(record, 'systemPrompt'), + }; + this.push({ type: 'config_updated', config }, time); + } + + private recordApprovalResult(record: WireRecord, time: number): void { + const approval: PermissionApprovalResultRecord = { + turnId: record['turnId'] as number, + toolCallId: record['toolCallId'] as string, + toolName: record['toolName'] as string, + action: record['action'] as string, + sessionApprovalRule: readString(record, 'sessionApprovalRule'), + result: record['result'] as PermissionApprovalResultRecord['result'], + }; + this.push({ type: 'approval_result', record: approval }, time); + } +} + +function goalSnapshot(state: FoldGoalState): GoalSnapshot { + return { + goalId: state.goalId, + objective: state.objective, + completionCriterion: state.completionCriterion, + status: state.status, + turnsUsed: state.turnsUsed, + tokensUsed: state.tokensUsed, + wallClockMs: state.wallClockMs, + budget: goalBudgetReport(state), + terminalReason: state.terminalReason, + }; +} + +function goalBudgetReport(state: FoldGoalState): GoalBudgetReport { + const tokenBudget = state.budgetLimits.tokenBudget ?? null; + const turnBudget = state.budgetLimits.turnBudget ?? null; + const wallClockBudgetMs = state.budgetLimits.wallClockBudgetMs ?? null; + const tokenBudgetReached = tokenBudget !== null && state.tokensUsed >= tokenBudget; + const turnBudgetReached = turnBudget !== null && state.turnsUsed >= turnBudget; + const wallClockBudgetReached = + wallClockBudgetMs !== null && state.wallClockMs >= wallClockBudgetMs; + return { + tokenBudget, + turnBudget, + wallClockBudgetMs, + remainingTokens: tokenBudget === null ? null : Math.max(0, tokenBudget - state.tokensUsed), + remainingTurns: turnBudget === null ? null : Math.max(0, turnBudget - state.turnsUsed), + remainingWallClockMs: + wallClockBudgetMs === null ? null : Math.max(0, wallClockBudgetMs - state.wallClockMs), + tokenBudgetReached, + turnBudgetReached, + wallClockBudgetReached, + overBudget: tokenBudgetReached || turnBudgetReached || wallClockBudgetReached, + }; +} + +function isRealUserInput(message: ContextMessage): boolean { + if (message.role !== 'user') return false; + const origin = message.origin; + if (origin === undefined) return true; + switch (origin.kind) { + case 'user': + return true; + case 'skill_activation': + case 'plugin_command': + return origin.trigger === 'user-slash'; + default: + return false; + } +} + +function readString(record: WireRecord, key: string): string | undefined { + const value = record[key]; + return typeof value === 'string' ? value : undefined; +} + +function readNumber(record: WireRecord, key: string): number | undefined { + const value = record[key]; + return typeof value === 'number' ? value : undefined; +} + +function readCompactionSummary(record: WireRecord): string { + const summary = record['summary']; + if (typeof summary === 'string') return summary; + const contextSummary = record['contextSummary']; + if (typeof contextSummary === 'string') return contextSummary; + if (summary !== null && typeof summary === 'object' && !Array.isArray(summary)) { + const content = (summary as { readonly content?: unknown }).content; + if (Array.isArray(content)) { + let text = ''; + for (const part of content) { + if (part !== null && typeof part === 'object') { + const typed = part as { readonly type?: unknown; readonly text?: unknown }; + if (typed.type === 'text' && typeof typed.text === 'string') text += typed.text; + } + } + return text; + } + } + return ''; +} diff --git a/packages/agent-core-v2/src/agent/replayBuilder/types.ts b/packages/agent-core-v2/src/agent/replayBuilder/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..97b5ac802f4495a51aab9019c13d3c32a992eddc --- /dev/null +++ b/packages/agent-core-v2/src/agent/replayBuilder/types.ts @@ -0,0 +1,64 @@ +import type { AgentTaskInfo } from '#/agent/task/task'; +import type { CompactionResult } from '#/agent/fullCompaction/types'; +import type { AgentConfigData, AgentConfigUpdateData } from '#/agent/profile/profile'; +import type { AgentContextData, ContextMessage } from '#/agent/contextMemory/types'; +import type { GoalChange, GoalSnapshot } from '#/features/goal/types'; +import type { PermissionApprovalResultRecord } from '#/agent/permissionRules/permissionRules'; +import type { PermissionData, PermissionMode } from '#/agent/permissionPolicy/types'; +import type { PlanData } from '#/features/plan/plan'; +import type { ToolInfo } from '#/tool/toolContract'; +import type { UsageStatus } from '#/agent/usage/usage'; +import type { SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; + +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonValue[] | { readonly [key: string]: JsonValue }; +export type JsonObject = { readonly [key: string]: JsonValue }; + +export interface SessionSummary { + readonly id: string; + readonly title?: string | undefined; + readonly lastPrompt?: string; + readonly workDir: string; + readonly sessionDir: string; + readonly createdAt: number; + readonly updatedAt: number; + readonly archived?: boolean | undefined; + readonly metadata?: JsonObject | undefined; + readonly additionalDirs?: readonly string[]; +} + +type AgentType = 'main' | 'sub'; + +export type AgentReplayRecordPayload = + | { type: 'message'; message: ContextMessage } + | { type: 'compaction'; result?: CompactionResult | 'cancelled'; instruction?: string } + | { + type: 'goal_updated'; + snapshot: GoalSnapshot; + change: GoalChange | { readonly kind: 'created' }; + } + | { type: 'plan_updated'; enabled: boolean } + | { type: 'config_updated'; config: AgentConfigUpdateData } + | { type: 'permission_updated'; mode: PermissionMode } + | { type: 'approval_result'; record: PermissionApprovalResultRecord }; + +export type AgentReplayRecord = { readonly time: number } & AgentReplayRecordPayload; + +export interface ResumedAgentState { + readonly type: AgentType; + readonly config: AgentConfigData; + readonly context: AgentContextData; + readonly replay: readonly AgentReplayRecord[]; + readonly permission: PermissionData; + readonly plan: PlanData; + readonly swarmMode?: boolean | undefined; + readonly usage: UsageStatus; + readonly tools: readonly ToolInfo[]; + readonly tasks: readonly AgentTaskInfo[]; +} + +export interface ResumeSessionResult extends SessionSummary { + readonly sessionMetadata: SessionMeta; + readonly agents: Readonly>; + readonly warning?: string | undefined; +} diff --git a/packages/agent-core-v2/src/agent/runtimeBinding/agentRuntime.ts b/packages/agent-core-v2/src/agent/runtimeBinding/agentRuntime.ts new file mode 100644 index 0000000000000000000000000000000000000000..8fe5b7714dc8a5286df1a8b20b70f2ed498cd511 --- /dev/null +++ b/packages/agent-core-v2/src/agent/runtimeBinding/agentRuntime.ts @@ -0,0 +1,126 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Emitter, type Event } from '#/_base/event'; +import type { IDisposable } from '#/_base/di/lifecycle'; +import { LifecycleScope } from '#/app/scopes'; +import type { Runtime, RuntimeBinding, RuntimeCapability, RuntimeLease } from '#/runtime/runtime'; +import { runtimeStatusAllows, type RuntimeGenerationSnapshot } from '#/runtime/runtimeRegistry'; +import { + IRuntimeResolver, + IWorkspaceInstanceManager, +} from '#/workspace/workspaceInstance/workspaceInstanceManager'; + +import { IAgentRuntimeBindingService } from './runtimeBinding'; + +export interface AgentRuntimeBindingSnapshot { + readonly binding: RuntimeBinding; + readonly available: boolean; + readonly runtime?: RuntimeGenerationSnapshot; +} + +export interface IAgentRuntimeService { + readonly _serviceBrand: undefined; + readonly onDidChange: Event; + inspect(): Runtime; + isAvailable(required?: readonly RuntimeCapability[]): boolean; + acquire(required?: readonly RuntimeCapability[]): RuntimeLease; +} + +export const IAgentRuntimeService: ServiceIdentifier = + createDecorator('agentRuntimeService'); + +export function inspectAgentRuntime(service: IAgentRuntimeService): Runtime { + return service.inspect(); +} + +export function snapshotAgentRuntimeBinding( + bindingService: IAgentRuntimeBindingService, + runtimeService: IAgentRuntimeService, +): AgentRuntimeBindingSnapshot { + const binding = bindingService.current; + try { + const runtime = runtimeService.inspect(); + return { + binding, + available: runtimeService.isAvailable(), + runtime: { + runtimeId: runtime.identity.runtimeId, + generation: runtime.identity.generation, + status: runtime.status, + capabilities: [...runtime.capabilities], + }, + }; + } catch { + return { binding, available: false }; + } +} + +export class AgentRuntimeService implements IAgentRuntimeService { + declare readonly _serviceBrand: undefined; + private readonly changeEmitter = new Emitter(); + readonly onDidChange = this.changeEmitter.event; + private readonly bindingSubscription: IDisposable; + private readonly workspaceSubscription: IDisposable; + private registrySubscription: IDisposable | undefined; + + constructor( + @IAgentRuntimeBindingService private readonly binding: IAgentRuntimeBindingService, + @IRuntimeResolver private readonly resolver: IRuntimeResolver, + @IWorkspaceInstanceManager private readonly workspaces: IWorkspaceInstanceManager, + ) { + this.bindingSubscription = this.binding.onDidChange(() => this.rebind()); + this.workspaceSubscription = this.workspaces.onDidChange((change) => { + if (change.workspaceId === this.binding.current.workspaceId) this.rebind(); + }); + this.bindRegistry(); + } + + inspect(): Runtime { + return this.resolver.inspect(this.binding.current); + } + + isAvailable(required: readonly RuntimeCapability[] = []): boolean { + try { + const runtime = this.inspect(); + return runtimeStatusAllows(runtime, required) && required.every((capability) => runtime.capabilities.has(capability)); + } catch { + return false; + } + } + + acquire(required: readonly RuntimeCapability[] = []): RuntimeLease { + return this.resolver.acquire(this.binding.current, required); + } + + dispose(): void { + this.registrySubscription?.dispose(); + this.workspaceSubscription.dispose(); + this.bindingSubscription.dispose(); + this.changeEmitter.dispose(); + } + + private rebind(): void { + this.bindRegistry(); + this.changeEmitter.fire(); + } + + private bindRegistry(): void { + this.registrySubscription?.dispose(); + const binding = this.binding.current; + const workspace = this.workspaces.get(binding.workspaceId); + this.registrySubscription = workspace?.runtimes.onDidChange((change) => { + if (change.runtimeId !== this.binding.current.runtimeId) return; + const current = workspace.runtimes.current(change.runtimeId); + if (change.current !== undefined && change.current !== current) return; + this.changeEmitter.fire(); + }); + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentRuntimeService, + AgentRuntimeService, + ScopeActivation.OnDemand, + 'agentRuntimeBinding', +); diff --git a/packages/agent-core-v2/src/agent/runtimeBinding/runtimeBinding.ts b/packages/agent-core-v2/src/agent/runtimeBinding/runtimeBinding.ts new file mode 100644 index 0000000000000000000000000000000000000000..4af970a4e4d7812beb4fdbc162d0392b6cd027de --- /dev/null +++ b/packages/agent-core-v2/src/agent/runtimeBinding/runtimeBinding.ts @@ -0,0 +1,21 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { Event } from '#/_base/event'; +import type { RuntimeBinding } from '#/runtime/runtime'; + +export interface IAgentRuntimeBindingService { + readonly _serviceBrand: undefined; + readonly current: RuntimeBinding; + readonly onDidChange: Event; + get(): RuntimeBinding; + set(binding: RuntimeBinding): RuntimeBinding; + switch(runtimeId: string): RuntimeBinding; +} + +export const IAgentRuntimeBindingService: ServiceIdentifier = createDecorator('agentRuntimeBindingService'); + +export interface IAgentRuntimeBindingSeed { + readonly _serviceBrand: undefined; + readonly binding: RuntimeBinding; +} + +export const IAgentRuntimeBindingSeed: ServiceIdentifier = createDecorator('agentRuntimeBindingSeed'); diff --git a/packages/agent-core-v2/src/agent/runtimeBinding/runtimeBindingOps.ts b/packages/agent-core-v2/src/agent/runtimeBinding/runtimeBindingOps.ts new file mode 100644 index 0000000000000000000000000000000000000000..62245a875fed80bab2affcf42f8889440fe21cf2 --- /dev/null +++ b/packages/agent-core-v2/src/agent/runtimeBinding/runtimeBindingOps.ts @@ -0,0 +1,29 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { z } from 'zod'; + +import { AgentEvent2 } from '#/app/event/event2'; +import type { RuntimeBinding } from '#/runtime/runtime'; +import { defineState } from '#/state/state'; + +const runtimeSetBindingSchema = z.object({ + agentId: z.string(), + workspaceId: z.string(), + runtimeId: z.string(), +}); + +export class RuntimeSetBinding extends AgentEvent2> { + static override readonly type = 'runtime.set_binding'; + static override readonly durable = true; + static override readonly schema = runtimeSetBindingSchema; +} +export interface RuntimeSetBinding { + readonly agentId: string; + readonly workspaceId: string; + readonly runtimeId: string; +} + +export const runtimeBindingKey = defineState( + 'runtimeBinding', + (): RuntimeBinding | undefined => undefined, +).replayable({ schema: z.custom() }) + .on(RuntimeSetBinding, (_s, e) => ({ workspaceId: e.workspaceId, runtimeId: e.runtimeId })); diff --git a/packages/agent-core-v2/src/agent/runtimeBinding/runtimeBindingService.ts b/packages/agent-core-v2/src/agent/runtimeBinding/runtimeBindingService.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce369f014476938d7262086fa92b581084056ced --- /dev/null +++ b/packages/agent-core-v2/src/agent/runtimeBinding/runtimeBindingService.ts @@ -0,0 +1,95 @@ +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { defineState } from '#/state/state'; +import type { IDisposable } from '#/_base/di/lifecycle'; +import { Emitter } from '#/_base/event'; +import { LifecycleScope } from '#/app/scopes'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentStateService } from '#/agent/state/agentState'; +import type { RuntimeBinding } from '#/runtime/runtime'; +import { RuntimeError } from '#/runtime/runtimeRegistry'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { IEventDispatcher } from '#/state/eventDispatcher'; +import { IRuntimeResolver } from '#/workspace/workspaceInstance/workspaceInstanceManager'; + +import { IAgentRuntimeBindingSeed, IAgentRuntimeBindingService } from './runtimeBinding'; +import { RuntimeSetBinding, runtimeBindingKey } from './runtimeBindingOps'; + +export const agentRuntimeBindingKey = defineState('runtime.binding', () => ({ workspaceId: '', runtimeId: 'local' })); + +export class AgentRuntimeBindingService implements IAgentRuntimeBindingService { + declare readonly _serviceBrand: undefined; + private readonly changeEmitter = new Emitter(); + readonly onDidChange = this.changeEmitter.event; + private readonly restoreHook: IDisposable; + + constructor( + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @IAgentStateService private readonly state: IAgentStateService, + @IAgentRuntimeBindingSeed seed: IAgentRuntimeBindingSeed, + @ISessionContext private readonly session: ISessionContext, + @IRuntimeResolver private readonly resolver: IRuntimeResolver, + @IEventDispatcher private readonly dispatcher: IEventDispatcher, + ) { + this.state.contributeState(agentRuntimeBindingKey); + this.state.contributeState(runtimeBindingKey); + const initial = this.state.get(runtimeBindingKey) ?? seed.binding; + this.assertSessionWorkspace(initial); + this.state.set(agentRuntimeBindingKey, initial); + this.restoreHook = dispatcher.hooks.onDidRestore.register('agent-runtime-binding', async (_ctx, next) => { + const replayed = this.state.get(runtimeBindingKey); + if (replayed === undefined) { + void this.dispatcher.dispatch( + new RuntimeSetBinding({ ...this.current, agentId: this.scopeContext.agentId }), + ); + } else { + this.assertSessionWorkspace(replayed); + this.state.set(agentRuntimeBindingKey, replayed); + } + await next(); + }); + } + + private assertSessionWorkspace(binding: RuntimeBinding): void { + if (binding.workspaceId !== this.session.workspaceId) { + throw new RuntimeError( + 'runtime.not_found', + `runtime binding workspace ${binding.workspaceId} does not match session workspace ${this.session.workspaceId}`, + ); + } + } + + get current(): RuntimeBinding { + return this.state.get(agentRuntimeBindingKey); + } + + get(): RuntimeBinding { + return this.current; + } + + set(binding: RuntimeBinding): RuntimeBinding { + this.assertSessionWorkspace(binding); + const lease = this.resolver.acquire(binding, []); + lease.dispose(); + if (binding.workspaceId === this.current.workspaceId && binding.runtimeId === this.current.runtimeId) { + return this.current; + } + const next = { workspaceId: binding.workspaceId, runtimeId: binding.runtimeId }; + void this.dispatcher.dispatch( + new RuntimeSetBinding({ ...next, agentId: this.scopeContext.agentId }), + ); + this.state.set(agentRuntimeBindingKey, next); + this.changeEmitter.fire(next); + return next; + } + + switch(runtimeId: string): RuntimeBinding { + return this.set({ workspaceId: this.session.workspaceId, runtimeId }); + } + + dispose(): void { + this.restoreHook.dispose(); + this.changeEmitter.dispose(); + } +} + +registerScopedService(LifecycleScope.Agent, IAgentRuntimeBindingService, AgentRuntimeBindingService, ScopeActivation.OnScopeCreated, 'agentRuntimeBinding'); diff --git a/packages/agent-core-v2/src/agent/scopeContext/scopeContext.ts b/packages/agent-core-v2/src/agent/scopeContext/scopeContext.ts new file mode 100644 index 0000000000000000000000000000000000000000..04f10f641a89eb32d7c04b083985a10360af02b1 --- /dev/null +++ b/packages/agent-core-v2/src/agent/scopeContext/scopeContext.ts @@ -0,0 +1,55 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { IAgentScopeHandle } from '#/_base/di/scope'; +import type { AgentContext } from '#/agent/agentContext/agentContext'; +import { AgentSpaceImpl } from '#/agent/agentContext/agentSpace'; + +export interface IAgentScopeContext { + readonly _serviceBrand: undefined; + + readonly agentId: string; + readonly forkedFrom?: string; + readonly agentContext: AgentContext; + scope(subKey?: string): string; +} + +export const IAgentScopeContext: ServiceIdentifier = + createDecorator('agentScopeContext'); + +export function makeAgentScopeContext(input: { + readonly agentId: string; + readonly agentScope: string; + readonly forkedFrom?: string; + readonly generation?: number; +}): IAgentScopeContext { + const { agentScope } = input; + const space = new AgentSpaceImpl(input.agentId); + const agentContext: AgentContext = Object.freeze({ + agentId: input.agentId, + generation: input.generation ?? 0, + space, + }); + space._bindContext(agentContext); + return { + _serviceBrand: undefined, + agentId: input.agentId, + forkedFrom: input.forkedFrom, + agentContext, + scope: (subKey?: string): string => { + if (subKey === undefined || subKey === '') return agentScope; + if (agentScope === '') return subKey; + return `${agentScope}/${subKey}`; + }, + }; +} + +export function agentContextOfScope(scope: IAgentScopeContext): AgentContext { + return scope.agentContext; +} + +export function agentContextOf(handle: IAgentScopeHandle): AgentContext { + return agentContextOfScope(handle.accessor.get(IAgentScopeContext)); +} + +export function tryAgentContextOf(handle: IAgentScopeHandle): AgentContext | undefined { + return handle.accessor.get(IAgentScopeContext)?.agentContext; +} diff --git a/packages/agent-core-v2/src/agent/shellCommand/shellCommand.ts b/packages/agent-core-v2/src/agent/shellCommand/shellCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..0007e1b002552ba91a63574ee0f55f310ebf00cc --- /dev/null +++ b/packages/agent-core-v2/src/agent/shellCommand/shellCommand.ts @@ -0,0 +1,23 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface RunShellCommandInput { + readonly command: string; + readonly commandId?: string; +} + +export interface RunShellCommandResult { + readonly stdout: string; + readonly stderr: string; + readonly isError?: boolean; + readonly backgrounded?: boolean; +} + +export interface IAgentShellCommandService { + readonly _serviceBrand: undefined; + + run(input: RunShellCommandInput): Promise; + cancel(commandId: string): void; +} + +export const IAgentShellCommandService: ServiceIdentifier = + createDecorator('agentShellCommandService'); diff --git a/packages/agent-core-v2/src/agent/shellCommand/shellCommandService.ts b/packages/agent-core-v2/src/agent/shellCommand/shellCommandService.ts new file mode 100644 index 0000000000000000000000000000000000000000..28c52cc6062249cea1d175f9cc750b6e2b06faa4 --- /dev/null +++ b/packages/agent-core-v2/src/agent/shellCommand/shellCommandService.ts @@ -0,0 +1,272 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { defineState } from '#/state/state'; +import { userCancellationReason } from '#/_base/utils/abort'; +import { escapeXml } from '#/_base/utils/xml-escape'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import type { PromptOrigin } from '#/agent/contextMemory/types'; +import { IAgentLoopService } from '#/agent/loop/loop'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentStateService } from '#/agent/state/agentState'; +import type { ToolUpdate } from '#/tool/toolContract'; +import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; +import { AgentEvent2 } from '#/app/event/event2'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { Error2, ErrorCodes } from '#/errors'; +import { IEventDispatcher } from '#/state/eventDispatcher'; + +import { + IAgentShellCommandService, + type RunShellCommandInput, + type RunShellCommandResult, +} from './shellCommand'; + +export interface ShellOutputPayload { + readonly agentId: string; + readonly commandId: string; + readonly update: ToolUpdate; + readonly taskId?: string; +} + +export class ShellOutput extends AgentEvent2 { + static override readonly type = 'shell.output'; + static override readonly observable = true; +} +export interface ShellOutput extends ShellOutputPayload {} + +export interface ShellStartedPayload { + readonly agentId: string; + readonly commandId: string; + readonly taskId: string; +} + +export class ShellStarted extends AgentEvent2 { + static override readonly type = 'shell.started'; + static override readonly observable = true; +} +export interface ShellStarted extends ShellStartedPayload {} + +export interface ShellCompletedPayload { + readonly agentId: string; + readonly commandId: string; + readonly isError: boolean; + readonly taskId?: string; +} + +export class ShellCompleted extends AgentEvent2 { + static override readonly type = 'shell.completed'; + static override readonly observable = true; +} +export interface ShellCompleted extends ShellCompletedPayload {} + +const SHELL_FOREGROUND_TIMEOUT_S = 2 * 60; + +export const shellCommandTasksKey = defineState>( + 'shellCommand.tasks', + () => new Map(), +); + +export class AgentShellCommandService implements IAgentShellCommandService { + declare readonly _serviceBrand: undefined; + private readonly shellCommandControllers = new Map(); + + constructor( + @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, + @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + @IAgentLoopService private readonly loop: IAgentLoopService, + @IEventDispatcher private readonly dispatcher: IEventDispatcher, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @IAgentStateService private readonly states: IAgentStateService, + @ITelemetryService private readonly telemetry: ITelemetryService, + ) { + this.states.contributeState(shellCommandTasksKey); + } + + private get shellCommandTasks(): Map { + return this.states.get(shellCommandTasksKey); + } + + async run(input: RunShellCommandInput): Promise { + this.appendShellInput(input.command); + + const controller = new AbortController(); + if (input.commandId !== undefined) { + this.shellCommandControllers.set(input.commandId, controller); + } + + let stdout = ''; + let stderr = ''; + const startedAt = Date.now(); + let isError = false; + let backgrounded = false; + try { + const bash = this.ensureBashTool(); + const execution = await bash.resolveExecution({ + command: input.command, + timeout: SHELL_FOREGROUND_TIMEOUT_S, + }); + if (execution.isError === true) { + const output = typeof execution.output === 'string' ? execution.output : 'Command failed.'; + this.appendShellOutput('', output); + isError = true; + return { stdout: '', stderr: output, isError: true }; + } + + const result = await execution.execute({ + turnId: -1, + toolCallId: 'shell-command', + signal: controller.signal, + onUpdate: (update: ToolUpdate) => { + if (update.kind === 'stdout') stdout += update.text ?? ''; + else if (update.kind === 'stderr') stderr += update.text ?? ''; + else return; + if (input.commandId !== undefined) { + void this.dispatcher.dispatch( + new ShellOutput({ + agentId: this.scopeContext.agentId, + commandId: input.commandId, + update, + taskId: this.shellCommandTasks.get(input.commandId), + }), + ); + } + }, + onForegroundTaskStart: (taskId: string) => { + if (input.commandId !== undefined) { + this.shellCommandTasks.set(input.commandId, taskId); + void this.dispatcher.dispatch( + new ShellStarted({ + agentId: this.scopeContext.agentId, + commandId: input.commandId, + taskId, + }), + ); + } + }, + }); + + isError = result.isError === true; + if (typeof result.output === 'string' && result.output.startsWith('task_id: ')) { + this.notifyBackgrounded(result.output); + backgrounded = true; + return { stdout: result.output, stderr: '', isError: false, backgrounded: true }; + } + if (isError && stdout.length === 0 && stderr.length === 0) { + stderr = typeof result.output === 'string' ? result.output : 'Command failed.'; + if (input.commandId !== undefined && stderr.length > 0) { + void this.dispatcher.dispatch( + new ShellOutput({ + agentId: this.scopeContext.agentId, + commandId: input.commandId, + update: { kind: 'stderr', text: stderr }, + taskId: this.shellCommandTasks.get(input.commandId), + }), + ); + } + } + if (input.commandId !== undefined) { + void this.dispatcher.dispatch( + new ShellCompleted({ + agentId: this.scopeContext.agentId, + commandId: input.commandId, + isError, + taskId: this.shellCommandTasks.get(input.commandId), + }), + ); + } + this.appendShellOutput(stdout, stderr, isError); + return { stdout, stderr, isError }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + stderr += message; + isError = true; + if (input.commandId !== undefined) { + if (message.length > 0) { + void this.dispatcher.dispatch( + new ShellOutput({ + agentId: this.scopeContext.agentId, + commandId: input.commandId, + update: { kind: 'stderr', text: message }, + taskId: this.shellCommandTasks.get(input.commandId), + }), + ); + } + void this.dispatcher.dispatch( + new ShellCompleted({ + agentId: this.scopeContext.agentId, + commandId: input.commandId, + isError: true, + taskId: this.shellCommandTasks.get(input.commandId), + }), + ); + } + this.appendShellOutput(stdout, stderr, true); + return { stdout, stderr, isError: true }; + } finally { + if (input.commandId !== undefined) { + this.shellCommandControllers.delete(input.commandId); + this.shellCommandTasks.delete(input.commandId); + } + this.telemetry.track2('shell_command_finished', { + duration_ms: Date.now() - startedAt, + is_error: isError, + backgrounded, + }); + } + } + + cancel(commandId: string): void { + this.shellCommandControllers.get(commandId)?.abort(userCancellationReason()); + } + + private ensureBashTool() { + const bash = this.toolRegistry.resolve('Bash'); + if (bash === undefined) { + throw new Error2(ErrorCodes.INTERNAL, 'Bash tool is not registered.'); + } + return bash; + } + + private appendShellInput(command: string): void { + const text = `\n${escapeXml(command)}\n`; + this.context.append({ + role: 'user', + content: [{ type: 'text', text }], + toolCalls: [], + origin: { kind: 'shell_command', phase: 'input' }, + }); + } + + private appendShellOutput(stdout: string, stderr: string, isError?: boolean): void { + const text = `${escapeXml(stdout)}${escapeXml(stderr)}`; + this.context.append({ + role: 'user', + content: [{ type: 'text', text }], + toolCalls: [], + origin: + isError === true + ? { kind: 'shell_command', phase: 'output', isError: true } + : { kind: 'shell_command', phase: 'output' }, + }); + } + + private notifyBackgrounded(output: string): void { + this.loop.submit( + { + message: { role: 'user', content: [{ type: 'text', text: output }] }, + meta: { origin: { kind: 'injection', variant: 'shell_command_backgrounded' } as PromptOrigin }, + }, + { steerIfActive: true }, + ); + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentShellCommandService, + AgentShellCommandService, + ScopeActivation.OnScopeCreated, + 'shellCommand', +); diff --git a/packages/agent-core-v2/src/agent/state/agentState.ts b/packages/agent-core-v2/src/agent/state/agentState.ts new file mode 100644 index 0000000000000000000000000000000000000000..d25e00c64d0abc015bee789e9501cacbb15343d8 --- /dev/null +++ b/packages/agent-core-v2/src/agent/state/agentState.ts @@ -0,0 +1,18 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { IDisposable } from '#/_base/di/lifecycle'; +import type { IStateRegistry } from '#/_base/state/stateRegistry'; +import type { ReplayableStateKey } from '#/state/state'; + +export interface IAgentStateService extends IStateRegistry { + readonly _serviceBrand: undefined; + replayableKeys(): readonly ReplayableStateKey[]; + onDidContributeReplayable( + listener: (key: ReplayableStateKey) => void, + ): IDisposable; + onDidWithdrawReplayable( + listener: (key: ReplayableStateKey) => void, + ): IDisposable; +} + +export const IAgentStateService: ServiceIdentifier = + createDecorator('agentStateService'); diff --git a/packages/agent-core-v2/src/agent/state/agentStateService.ts b/packages/agent-core-v2/src/agent/state/agentStateService.ts new file mode 100644 index 0000000000000000000000000000000000000000..6463b0b4f25e80c1563387d6be889f9bf6f3aed4 --- /dev/null +++ b/packages/agent-core-v2/src/agent/state/agentStateService.ts @@ -0,0 +1,84 @@ +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { type IDisposable, toDisposable } from '#/_base/di/lifecycle'; +import { StateRegistry, type StateKey } from '#/_base/state/stateRegistry'; +import { ISessionStateService } from '#/session/state/sessionState'; +import type { ReplayableStateKey } from '#/state/state'; + +import { IAgentStateService } from './agentState'; + +export class AgentStateService extends StateRegistry implements IAgentStateService { + declare readonly _serviceBrand: undefined; + protected override readonly inspectScope = 'agent'; + + private readonly replayables: ReplayableStateKey[] = []; + private readonly contributeListeners = new Set<(key: ReplayableStateKey) => void>(); + private readonly withdrawListeners = new Set<(key: ReplayableStateKey) => void>(); + + constructor(@ISessionStateService sessionState?: ISessionStateService) { + super(); + this.inspectParent = sessionState; + } + + override contributeState(key: StateKey): IDisposable { + const meta = (key as Partial>).replayable; + if (typeof meta !== 'object' || meta === null) { + return super.contributeState(key); + } + const replayableKey = key as unknown as ReplayableStateKey; + const registration = this.contributeKey(key); + this.replayables.push(replayableKey); + try { + for (const listener of this.contributeListeners) { + listener(replayableKey); + } + } catch (error) { + registration.dispose(); + const index = this.replayables.indexOf(replayableKey); + if (index !== -1) this.replayables.splice(index, 1); + for (const listener of this.withdrawListeners) { + listener(replayableKey); + } + throw error; + } + return toDisposable(() => { + registration.dispose(); + const index = this.replayables.indexOf(replayableKey); + if (index !== -1) this.replayables.splice(index, 1); + for (const listener of this.withdrawListeners) { + listener(replayableKey); + } + }); + } + + replayableKeys(): readonly ReplayableStateKey[] { + return [...this.replayables]; + } + + onDidContributeReplayable( + listener: (key: ReplayableStateKey) => void, + ): IDisposable { + this.contributeListeners.add(listener); + return toDisposable(() => { + this.contributeListeners.delete(listener); + }); + } + + onDidWithdrawReplayable( + listener: (key: ReplayableStateKey) => void, + ): IDisposable { + this.withdrawListeners.add(listener); + return toDisposable(() => { + this.withdrawListeners.delete(listener); + }); + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentStateService, + AgentStateService, + ScopeActivation.OnScopeCreated, + 'state', +); diff --git a/packages/agent-core-v2/src/agent/task/configSection.ts b/packages/agent-core-v2/src/agent/task/configSection.ts new file mode 100644 index 0000000000000000000000000000000000000000..d4f17b643d20839ed54be3d11a3a8b5bcd63358f --- /dev/null +++ b/packages/agent-core-v2/src/agent/task/configSection.ts @@ -0,0 +1,90 @@ +import { z } from 'zod'; + +import { parseBooleanEnv } from '#/_base/utils/env'; +import { + type EnvBindings, + envBindings, + stripEnvBoundFields, + type IConfigService, +} from '#/app/config/config'; +import { registerConfigSection } from '#/app/config/configSectionContributions'; + +export const TASK_SECTION = 'task'; +export const LEGACY_BACKGROUND_SECTION = 'background'; + +export const PrintBackgroundModeSchema = z.enum(['exit', 'drain', 'steer']); + +export type PrintBackgroundMode = z.infer; + +export const AgentTaskConfigSchema = z.object({ + maxRunningTasks: z.number().int().min(1).optional(), + keepAliveOnExit: z.boolean().optional(), + bashAutoBackgroundOnTimeout: z.boolean().optional(), + bashTaskTimeoutS: z.number().int().min(0).optional(), + killGracePeriodMs: z.number().int().min(0).optional(), + printWaitCeilingS: z.number().int().min(1).optional(), + printBackgroundMode: PrintBackgroundModeSchema.optional(), + printMaxTurns: z.number().int().min(1).optional(), +}); + +export type AgentTaskConfig = z.infer; + +export function resolveAgentTaskConfig(config: IConfigService): AgentTaskConfig | undefined { + const legacy = config.get(LEGACY_BACKGROUND_SECTION); + const current = config.get(TASK_SECTION); + if (legacy === undefined) return current; + if (current === undefined) return legacy; + return { ...legacy, ...current }; +} + +export function resolvePrintBackgroundMode(config: IConfigService): PrintBackgroundMode { + const section = resolveAgentTaskConfig(config); + if (section?.printBackgroundMode !== undefined) return section.printBackgroundMode; + return section?.keepAliveOnExit === true ? 'drain' : 'steer'; +} + +export const KEEP_ALIVE_ON_EXIT_ENV = 'KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT'; +export const MAX_RUNNING_TASKS_ENV = 'KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS'; +export const BASH_TASK_TIMEOUT_S_ENV = 'KIMI_CODE_BACKGROUND_BASH_TASK_TIMEOUT_S'; +export const PRINT_WAIT_CEILING_S_ENV = 'KIMI_CODE_BACKGROUND_PRINT_WAIT_CEILING_S'; +export const PRINT_BACKGROUND_MODE_ENV = 'KIMI_CODE_BACKGROUND_PRINT_BACKGROUND_MODE'; +export const PRINT_MAX_TURNS_ENV = 'KIMI_CODE_BACKGROUND_PRINT_MAX_TURNS'; + +function parsePositiveInt(raw: string): number | undefined { + const value = raw.trim(); + if (value.length === 0 || !/^\d+$/.test(value)) return undefined; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; +} + +function parseNonNegativeInt(raw: string): number | undefined { + const value = raw.trim(); + if (value.length === 0 || !/^\d+$/.test(value)) return undefined; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined; +} + +function parsePrintBackgroundMode(raw: string): PrintBackgroundMode | undefined { + const parsed = PrintBackgroundModeSchema.safeParse(raw.trim()); + return parsed.success ? parsed.data : undefined; +} + +export const taskEnvBindings: EnvBindings = envBindings(AgentTaskConfigSchema, { + keepAliveOnExit: { env: KEEP_ALIVE_ON_EXIT_ENV, parse: parseBooleanEnv }, + maxRunningTasks: { env: MAX_RUNNING_TASKS_ENV, parse: parsePositiveInt }, + bashTaskTimeoutS: { env: BASH_TASK_TIMEOUT_S_ENV, parse: parseNonNegativeInt }, + printWaitCeilingS: { env: PRINT_WAIT_CEILING_S_ENV, parse: parsePositiveInt }, + printBackgroundMode: { env: PRINT_BACKGROUND_MODE_ENV, parse: parsePrintBackgroundMode }, + printMaxTurns: { env: PRINT_MAX_TURNS_ENV, parse: parsePositiveInt }, +}); + +export const stripTaskEnv = stripEnvBoundFields(taskEnvBindings); + +registerConfigSection(TASK_SECTION, AgentTaskConfigSchema, { + env: taskEnvBindings, + stripEnv: stripTaskEnv, +}); +registerConfigSection(LEGACY_BACKGROUND_SECTION, AgentTaskConfigSchema, { + env: taskEnvBindings, + stripEnv: stripTaskEnv, +}); diff --git a/packages/agent-core-v2/src/agent/task/errors.ts b/packages/agent-core-v2/src/agent/task/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..64e8161a4ba4b2bef5070c43285a3a05d8d8db2d --- /dev/null +++ b/packages/agent-core-v2/src/agent/task/errors.ts @@ -0,0 +1,11 @@ +import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; + +export const TaskErrors = { + codes: { + TASK_ID_EMPTY: 'task.task_id_empty', + TASK_LIMIT_EXCEEDED: 'task.limit_exceeded', + }, + retryable: ['task.limit_exceeded'], +} as const satisfies ErrorDomain; + +registerErrorDomain(TaskErrors); diff --git a/packages/agent-core-v2/src/agent/task/notificationXml.ts b/packages/agent-core-v2/src/agent/task/notificationXml.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc71d99a270486003f101c57f725e47244d58add --- /dev/null +++ b/packages/agent-core-v2/src/agent/task/notificationXml.ts @@ -0,0 +1,42 @@ +import { escapeXmlAttr } from '#/_base/utils/xml-escape'; + +export function renderNotificationXml(data: Record): string { + const id = stringAttr(data['id'], 'unknown'); + const category = stringAttr(data['category'], 'unknown'); + const type = stringAttr(data['type'], 'unknown'); + const sourceKind = stringAttr(data['source_kind'], 'unknown'); + const sourceId = stringAttr(data['source_id'], 'unknown'); + const agentId = optionalStringAttr(data['agent_id']); + const title = typeof data['title'] === 'string' ? data['title'] : ''; + const severity = typeof data['severity'] === 'string' ? data['severity'] : ''; + const body = typeof data['body'] === 'string' ? data['body'] : ''; + const children = childBlocks(data['children'] ?? data['extraBlocks']); + + const agentIdAttr = agentId === undefined ? '' : ` agent_id="${agentId}"`; + const lines: string[] = [ + ``, + ]; + if (title.length > 0) lines.push(`Title: ${title}`); + if (severity.length > 0) lines.push(`Severity: ${severity}`); + if (body.length > 0) lines.push(body); + lines.push(...children); + + lines.push(''); + return lines.join('\n'); +} + +function stringAttr(value: unknown, fallback: string): string { + if (typeof value !== 'string' || value.length === 0) return fallback; + return escapeXmlAttr(value); +} + +function optionalStringAttr(value: unknown): string | undefined { + if (typeof value !== 'string' || value.length === 0) return undefined; + return escapeXmlAttr(value); +} + +function childBlocks(value: unknown): string[] { + if (typeof value === 'string' && value.length > 0) return [value]; + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === 'string' && item.length > 0); +} diff --git a/packages/agent-core-v2/src/agent/task/persist.ts b/packages/agent-core-v2/src/agent/task/persist.ts new file mode 100644 index 0000000000000000000000000000000000000000..66bc5860df6234e6c20f89f3735d15d649182037 --- /dev/null +++ b/packages/agent-core-v2/src/agent/task/persist.ts @@ -0,0 +1,287 @@ +import { join } from 'pathe'; + +import { BugIndicatingError } from '#/errors'; +import type { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import type { IFileSystemStorageService } from '#/persistence/interface/storage'; + +import type { AgentTaskInfo, AgentTaskStatus } from './types'; + +const VALID_TASK_ID: RegExp = /^[a-z0-9]+(?:-[a-z0-9]+)*-[0-9a-z]{8}$/; + +const TASKS_SCOPE = 'tasks'; +const OUTPUT_LOG_KEY = 'output.log'; +const JSON_SUFFIX = '.json'; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +type PersistedTask = AgentTaskInfo; + +type DiskPersistedTask = PersistedTask | LegacyPersistedTask; + +export interface AgentTaskPersistenceRoot { + readonly dir: string; + readonly scope: string; +} + +export interface AgentTaskStoredOutputSnapshot { + readonly outputPath: string; + readonly outputSizeBytes: number; + readonly previewBytes: number; + readonly truncated: boolean; + readonly preview: string; +} + +interface ListedTask { + readonly keyId: string; + readonly task: PersistedTask; +} + +interface TaskOutputData { + readonly root: AgentTaskPersistenceRoot; + readonly data: Uint8Array; +} + +function validateTaskId(taskId: string): void { + if (!VALID_TASK_ID.test(taskId)) { + throw new BugIndicatingError(`Invalid task id: "${taskId}"`); + } +} + +export class AgentTaskPersistence { + constructor( + private readonly agentDir: string, + private readonly agentScope: string, + private readonly docs: IAtomicDocumentStore, + private readonly bytes: IFileSystemStorageService, + private readonly fallbackRoot?: AgentTaskPersistenceRoot, + ) {} + + private primaryRoot(): AgentTaskPersistenceRoot { + return { dir: this.agentDir, scope: this.agentScope }; + } + + private tasksScope(root: AgentTaskPersistenceRoot = this.primaryRoot()): string { + return `${root.scope}/${TASKS_SCOPE}`; + } + + private taskOutputScope( + taskId: string, + root: AgentTaskPersistenceRoot = this.primaryRoot(), + ): string { + validateTaskId(taskId); + return `${root.scope}/${TASKS_SCOPE}/${taskId}`; + } + + private taskOutputFileAt(taskId: string, root: AgentTaskPersistenceRoot): string { + validateTaskId(taskId); + return join(root.dir, TASKS_SCOPE, taskId, OUTPUT_LOG_KEY); + } + + taskOutputFile(taskId: string): string { + return this.taskOutputFileAt(taskId, this.primaryRoot()); + } + + async writeTask(task: PersistedTask): Promise { + validateTaskId(task.taskId); + await this.docs.set(this.tasksScope(), `${task.taskId}${JSON_SUFFIX}`, task); + } + + async readTask(taskId: string): Promise { + validateTaskId(taskId); + const key = `${taskId}${JSON_SUFFIX}`; + const task = await this.docs.get(this.tasksScope(), key); + if (task !== undefined) { + return isReadablePersistedTask(task) ? normalizePersistedTask(task) : undefined; + } + const fallbackRoot = this.fallbackRoot; + if (fallbackRoot === undefined) return undefined; + const fallback = await this.docs.get(this.tasksScope(fallbackRoot), key); + if (fallback === undefined || !isReadablePersistedTask(fallback)) return undefined; + return normalizePersistedTask(fallback); + } + + async appendTaskOutput(taskId: string, chunk: string): Promise { + if (chunk.length === 0) return; + await this.bytes.append(this.taskOutputScope(taskId), OUTPUT_LOG_KEY, textEncoder.encode(chunk)); + } + + async taskOutputSizeBytes(taskId: string): Promise { + const output = await this.readTaskOutputData(taskId); + return output?.data.byteLength ?? 0; + } + + async taskOutputExists(taskId: string): Promise { + return (await this.readTaskOutputData(taskId)) !== undefined; + } + + async readTaskOutputBytes(taskId: string, offset: number, maxBytes: number): Promise { + const start = Math.max(0, Math.trunc(offset)); + const limit = Math.max(0, Math.trunc(maxBytes)); + if (limit === 0) return ''; + const output = await this.readTaskOutputData(taskId); + if (output === undefined || start >= output.data.byteLength) return ''; + const end = Math.min(output.data.byteLength, start + limit); + return textDecoder.decode(output.data.subarray(start, end)); + } + + async readTaskOutputSnapshot( + taskId: string, + maxPreviewBytes: number, + ): Promise { + const output = await this.readTaskOutputData(taskId); + if (output === undefined) return undefined; + const previewLimit = Math.max(0, Math.trunc(maxPreviewBytes)); + const previewBytes = Math.min(previewLimit, output.data.byteLength); + const previewOffset = output.data.byteLength - previewBytes; + return { + outputPath: this.taskOutputFileAt(taskId, output.root), + outputSizeBytes: output.data.byteLength, + previewBytes, + truncated: previewOffset > 0, + preview: textDecoder.decode(output.data.subarray(previewOffset)), + }; + } + + async listTasks(): Promise { + const primary = await this.listTasksAt(this.primaryRoot()); + const tasks = [...primary.tasks]; + const fallbackRoot = this.fallbackRoot; + if (fallbackRoot !== undefined) { + const fallback = await this.listTasksAt(fallbackRoot); + for (const entry of fallback.tasks) { + if (!primary.reservedIds.has(entry.keyId)) tasks.push(entry); + } + } + return tasks.map((entry) => entry.task).toSorted((a, b) => a.taskId.localeCompare(b.taskId)); + } + + private async listTasksAt(root: AgentTaskPersistenceRoot): Promise<{ + readonly reservedIds: ReadonlySet; + readonly tasks: readonly ListedTask[]; + }> { + const keys = (await this.docs.list(this.tasksScope(root))).toSorted(); + const reservedIds = new Set(); + const tasks: ListedTask[] = []; + for (const key of keys) { + if (!key.endsWith(JSON_SUFFIX)) continue; + const id = key.slice(0, -JSON_SUFFIX.length); + if (!VALID_TASK_ID.test(id)) continue; + reservedIds.add(id); + let task: DiskPersistedTask | undefined; + try { + task = await this.docs.get(this.tasksScope(root), key); + } catch { + continue; + } + if (task === undefined || !isReadablePersistedTask(task)) continue; + tasks.push({ keyId: id, task: normalizePersistedTask(task) }); + } + return { reservedIds, tasks }; + } + + private async readTaskOutputData(taskId: string): Promise { + const primaryRoot = this.primaryRoot(); + const primary = await this.bytes.read(this.taskOutputScope(taskId, primaryRoot), OUTPUT_LOG_KEY); + if (primary !== undefined) return { root: primaryRoot, data: primary }; + const fallbackRoot = this.fallbackRoot; + if (fallbackRoot === undefined) return undefined; + const fallback = await this.bytes.read( + this.taskOutputScope(taskId, fallbackRoot), + OUTPUT_LOG_KEY, + ); + return fallback === undefined ? undefined : { root: fallbackRoot, data: fallback }; + } +} + +function normalizePersistedTask(task: DiskPersistedTask): PersistedTask { + if (isLegacyPersistedTask(task)) return legacyPersistedTaskToInfo(task); + return { + ...task, + detached: task.detached ?? true, + }; +} + +type LegacyAgentTaskStatus = + | 'running' + | 'awaiting_approval' + | 'completed' + | 'failed' + | 'killed' + | 'lost'; + +interface LegacyPersistedTask { + readonly task_id: string; + readonly command: string; + readonly description: string; + readonly pid: number; + readonly started_at: number; + readonly ended_at: number | null; + readonly exit_code: number | null; + readonly status: LegacyAgentTaskStatus; + readonly timed_out?: boolean; + readonly stop_reason?: string; + readonly timeout_ms?: number; + readonly agent_id?: string; + readonly subagent_type?: string; +} + +function legacyPersistedTaskToInfo(task: LegacyPersistedTask): PersistedTask { + const status = legacyStatusToCurrent(task); + const stopReason = optionalNonEmptyString(task.stop_reason); + const timeoutMs = typeof task.timeout_ms === 'number' ? task.timeout_ms : undefined; + const base = { + taskId: task.task_id, + description: task.description, + status, + detached: true, + startedAt: task.started_at, + endedAt: task.ended_at, + stopReason, + timeoutMs, + }; + + if (task.task_id.startsWith('agent-')) { + return { + ...base, + kind: 'agent', + agentId: optionalNonEmptyString(task.agent_id), + subagentType: optionalNonEmptyString(task.subagent_type), + }; + } + + return { + ...base, + kind: 'process', + command: task.command, + pid: task.pid, + exitCode: task.exit_code, + }; +} + +function legacyStatusToCurrent(task: LegacyPersistedTask): AgentTaskStatus { + if (task.status === 'awaiting_approval') return 'running'; + if (task.status === 'failed' && task.timed_out === true) return 'timed_out'; + return task.status; +} + +function isReadablePersistedTask(obj: unknown): obj is DiskPersistedTask { + return ( + isRecord(obj) && + (typeof obj['taskId'] === 'string' || typeof obj['task_id'] === 'string') + ); +} + +function isLegacyPersistedTask(task: DiskPersistedTask): task is LegacyPersistedTask { + return 'task_id' in task; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function optionalNonEmptyString(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} diff --git a/packages/agent-core-v2/src/agent/task/printDefaults.ts b/packages/agent-core-v2/src/agent/task/printDefaults.ts new file mode 100644 index 0000000000000000000000000000000000000000..96458cba026fda89b5bf0afd106177cb48f7c9e9 --- /dev/null +++ b/packages/agent-core-v2/src/agent/task/printDefaults.ts @@ -0,0 +1,58 @@ +import { MAX_TIMER_DELAY_MS } from '#/_base/utils/timer'; +import { ConfigTarget, type ConfigInspectValue, type IConfigService } from '#/app/config/config'; +import { LOOP_CONTROL_SECTION } from '#/agent/loop/configSection'; +import { SWARM_SECTION } from '#/features/swarm/configSection'; +import { SUBAGENT_SECTION } from '#/session/subagent/configSection'; + +import { LEGACY_BACKGROUND_SECTION, TASK_SECTION } from './configSection'; + +export const PRINT_WAIT_CEILING_S_DEFAULT = Math.floor(MAX_TIMER_DELAY_MS / 1000); + +export const PRINT_MAX_TURNS_DEFAULT = 100_000; + +export const PRINT_BASH_TASK_TIMEOUT_S_DEFAULT = 0; + +export const PRINT_SUBAGENT_TIMEOUT_MS_DEFAULT = 0; + +export const PRINT_SWARM_TIMEOUT_MS_DEFAULT = 0; + +type SectionValue = Record; + +function isUnset(inspected: ConfigInspectValue, key: string): boolean { + if (inspected.userValue?.[key] !== undefined) return false; + if (inspected.memoryValue?.[key] !== undefined) return false; + const effective = inspected.value?.[key]; + if (effective === undefined) return true; + return inspected.defaultValue?.[key] === effective; +} + +async function fillSectionDefault( + config: IConfigService, + domain: string, + key: string, + value: number, + legacyUserValue?: SectionValue, +): Promise { + const inspected = config.inspect(domain); + if (!isUnset(inspected, key)) return; + if (legacyUserValue?.[key] !== undefined) return; + await config.set(domain, { ...inspected.value, [key]: value }, ConfigTarget.Memory); +} + +export async function applyPrintModeConfigDefaults(config: IConfigService): Promise { + await fillSectionDefault( + config, + TASK_SECTION, + 'bashTaskTimeoutS', + PRINT_BASH_TASK_TIMEOUT_S_DEFAULT, + config.inspect(LEGACY_BACKGROUND_SECTION).userValue, + ); + await fillSectionDefault(config, LOOP_CONTROL_SECTION, 'maxStepsPerTurn', 0); + await fillSectionDefault( + config, + SUBAGENT_SECTION, + 'timeoutMs', + PRINT_SUBAGENT_TIMEOUT_MS_DEFAULT, + ); + await fillSectionDefault(config, SWARM_SECTION, 'timeoutMs', PRINT_SWARM_TIMEOUT_MS_DEFAULT); +} diff --git a/packages/agent-core-v2/src/agent/task/task.ts b/packages/agent-core-v2/src/agent/task/task.ts new file mode 100644 index 0000000000000000000000000000000000000000..2a362988b0607583e6993073bcffaaa52b72d1e7 --- /dev/null +++ b/packages/agent-core-v2/src/agent/task/task.ts @@ -0,0 +1,106 @@ +import { createDecorator } from '#/_base/di/instantiation'; +import type { ITaskHandle } from '#/app/task/task'; +import type { + AgentTask, + AgentTaskInfo, + AgentTaskInfoBase, + AgentTaskStatus, +} from './types'; + +export { AgentTaskPersistence } from './persist'; +export type { + AgentTask, + AgentTaskInfo, + AgentTaskInfoBase, + AgentTaskKind, + AgentTaskStatus, +} from './types'; + +export interface AgentTaskLoadOptions { + readonly replace?: boolean; +} + +export interface AgentTaskOutputSnapshot { + readonly outputPath?: string; + readonly outputSizeBytes: number; + readonly previewBytes: number; + readonly truncated: boolean; + readonly fullOutputAvailable: boolean; + readonly preview: string; +} + +export interface RegisterAgentTaskOptions { + readonly detached?: boolean; + readonly timeoutMs?: number; + readonly detachTimeoutMs?: number; + readonly autoBackgroundOnTimeout?: boolean; + readonly signal?: AbortSignal; +} + +export type ForegroundTaskReleaseReason = 'detached' | 'timeout_detached' | 'terminal'; + +export interface AgentTaskTrackOptions { + readonly idPrefix?: string; + readonly description: string; + readonly detached?: boolean; + readonly timeoutMs?: number; + readonly detachTimeoutMs?: number; + readonly signal?: AbortSignal; + readonly forceStop?: () => Promise; + readonly onDetach?: () => void; + readonly toInfo: (base: AgentTaskInfoBase) => AgentTaskInfo; +} + +export interface IAgentTaskEntry { + readonly taskId: string; + readonly onDidDetach: Promise; +} + +export interface AgentTaskNotificationContext { + readonly agentId: string; + readonly notificationType: string; + readonly title: string; + readonly body: string; + readonly severity: 'info' | 'warning'; + readonly sourceKind: string; + readonly sourceId: string; +} + +export interface AgentTaskWaitDelivery { + readonly taskId: string; + readonly status: AgentTaskStatus; +} + +export interface IAgentTaskService { + readonly _serviceBrand: undefined; + + track(handle: ITaskHandle, options: AgentTaskTrackOptions): IAgentTaskEntry; + registerTask(task: AgentTask, options?: RegisterAgentTaskOptions): string; + getTask(taskId: string): AgentTaskInfo | undefined; + list(activeOnly?: boolean, limit?: number): readonly AgentTaskInfo[]; + persistOutput(taskId: string): void; + getOutputSnapshot( + taskId: string, + maxPreviewBytes: number, + ): Promise; + readOutput(taskId: string, tail?: number): Promise; + suppressTerminalNotification(taskId: string): Promise; + suppressAllTerminalNotifications(): Promise; + markTasksDeliveredViaWait(tasks: readonly AgentTaskWaitDelivery[]): void; + detach(taskId: string): AgentTaskInfo | undefined; + stop(taskId: string, reason?: string): Promise; + stopByUser(taskId: string): Promise; + stopAll(reason?: string): Promise; + stopAllOnExit(reason: string): Promise; + wait( + taskId: string, + timeoutMs?: number, + signal?: AbortSignal, + ): Promise; + waitForForegroundRelease( + taskId: string, + ): Promise; +} + +export const IAgentTaskService = + createDecorator('agentTaskService'); diff --git a/packages/agent-core-v2/src/agent/task/taskOps.ts b/packages/agent-core-v2/src/agent/task/taskOps.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc6aebc15ac12c3a1efac1875f689a557e8664c6 --- /dev/null +++ b/packages/agent-core-v2/src/agent/task/taskOps.ts @@ -0,0 +1,88 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { z } from 'zod'; + +import { AgentEvent2 } from '#/app/event/event2'; +import { defineState } from '#/state/state'; + +import type { AgentTaskNotificationContext } from './task'; +import type { AgentTaskInfo } from './types'; + +export type TaskModelState = Map; + +const taskStartedSchema = z.object({ + agentId: z.string(), + info: z.custom(), +}); + +export class TaskStarted extends AgentEvent2> { + static override readonly type = 'task.started'; + static override readonly durable = true; + static override readonly observable = true; + static override readonly schema = taskStartedSchema; +} +export interface TaskStarted { + readonly agentId: string; + readonly info: AgentTaskInfo; +} + +const taskTerminatedSchema = z.object({ + agentId: z.string(), + info: z.custom(), + outputTail: z.string().optional(), +}); + +export class TaskTerminated extends AgentEvent2> { + static override readonly type = 'task.terminated'; + static override readonly durable = true; + static override readonly schema = taskTerminatedSchema; +} +export interface TaskTerminated { + readonly agentId: string; + readonly info: AgentTaskInfo; + readonly outputTail?: string; +} + +export interface TaskTerminatedNoticePayload { + readonly agentId: string; + readonly info: AgentTaskInfo; +} + +export class TaskTerminatedNotice extends AgentEvent2 { + static override readonly type = 'task.terminated'; + static override readonly observable = true; +} +export interface TaskTerminatedNotice extends TaskTerminatedNoticePayload {} + +export class TaskNotified extends AgentEvent2 { + static override readonly type = 'task.notified'; + static override readonly observable = true; +} +export interface TaskNotified extends AgentTaskNotificationContext {} + +const taskWaitDeliveredSchema = z.object({ + agentId: z.string(), + keys: z.array(z.string()), +}); + +export class TaskWaitDelivered extends AgentEvent2> { + static override readonly type = 'task.waitDelivered'; + static override readonly durable = true; + static override readonly schema = taskWaitDeliveredSchema; +} +export interface TaskWaitDelivered { + readonly agentId: string; + readonly keys: string[]; +} + +export const taskKey = defineState('task', (): TaskModelState => new Map()).replayable({ + schema: z.custom(), +}) + .on(TaskStarted, (s, e) => { + s.set(e.info.taskId, e.info); + }) + .on(TaskTerminated, (s, e, ctx) => { + s.set(e.info.taskId, e.info); + if (e instanceof TaskTerminated) { + ctx.emit(new TaskTerminatedNotice({ agentId: e.agentId, info: e.info })); + } + }); diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts new file mode 100644 index 0000000000000000000000000000000000000000..69e4254e18f377cac7fa34d06903efa630775702 --- /dev/null +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -0,0 +1,1632 @@ +import { randomBytes } from 'node:crypto'; +import { join } from 'pathe'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; + +import type { ContentPart } from '#human/llm/message'; + +import { Disposable } from '#/_base/di/lifecycle'; +import { ILogService } from '#/_base/log/log'; +import { defineState } from '#/state/state'; +import { + abortable, + userCancellationReason, +} from '#/_base/utils/abort'; +import { setClampedTimeout } from '#/_base/utils/timer'; +import { escapeXml, escapeXmlAttr, escapeXmlTags } from '#/_base/utils/xml-escape'; +import { IEventBus, ISessionEventBus } from '#/app/event/eventBus'; +import { Error2, ErrorCodes } from '#/errors'; +import { z } from 'zod'; +import { + ContextAppendMessage, + ContextSpliced, +} from '#/agent/contextMemory/contextEvents'; +import '#/agent/contextMemory/conversationTime'; +import { IAgentConversationUndoParticipantRegistry } from '#/agent/contextMemory/conversationUndoParticipants'; +import { IEventDispatcher } from '#/state/eventDispatcher'; +import type { TaskOrigin } from '#/agent/contextMemory/types'; +import { IAgentReminderService } from '#/features/reminder/reminderService'; +import { IAgentLoopService, type LoopNotifyHandle } from '#/agent/loop/loop'; +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, + type AgentTaskInfoBase, + type AgentTaskSettlement, +} from './types'; +import { renderNotificationXml } from './notificationXml'; + +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { IConfigService } from '#/app/config/config'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { IFileSystemStorageService } from '#/persistence/interface/storage'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { + IAgentTaskService, + type AgentTaskLoadOptions, + type AgentTask, + type AgentTaskInfo, + type AgentTaskOutputSnapshot, + type AgentTaskStatus, + type AgentTaskTrackOptions, + type AgentTaskWaitDelivery, + type ForegroundTaskReleaseReason, + type IAgentTaskEntry, + type RegisterAgentTaskOptions, +} from './task'; +import { resolveAgentTaskConfig } from './configSection'; +import { AgentTaskPersistence } from './persist'; +import { taskKey, TaskNotified, TaskStarted, TaskTerminated, TaskWaitDelivered } from './taskOps'; +import { formatTaskList } from '#/agent/tools/task/task-list/taskListTool'; +import '#/agent/tools/task/task-output/taskOutputTool'; +import '#/agent/tools/task/task-stop/taskStopTool'; +import '#/agent/tools/task/task-wait/taskWaitTool'; + +interface ForegroundRelease { + readonly promise: Promise; + resolve(reason: ForegroundTaskReleaseReason): void; +} + +type AgentTaskNotification = Record & { + readonly id: string; + readonly category: 'task'; + readonly type: string; + readonly source_kind: 'background_task'; + readonly source_id: string; + readonly agent_id?: string | undefined; + readonly title: string; + readonly severity: 'info' | 'warning'; + readonly body: string; + readonly children?: readonly string[] | undefined; +}; + +interface AgentTaskNotificationBuildContext { + readonly content: readonly ContentPart[]; + readonly origin: TaskOrigin; + readonly notification: AgentTaskNotification; +} + +export const taskNotificationDeliveryKey = defineState( + 'task.notificationDelivery', + (): readonly string[] => [], +) + .replayable({ schema: z.custom() }) + .undoable() + .on(ContextAppendMessage, (s, e) => { + const origin = taskOriginFromMessage(e.message); + if (origin === undefined) return; + const key = notificationKey(origin); + if (!s.includes(key)) { + s.push(key); + } + }) + .on(TaskWaitDelivered, (s, e) => { + for (const key of e.keys) { + if (!s.includes(key)) { + s.push(key); + } + } + }); + +interface ManagedTask { + readonly taskId: string; + readonly task: AgentTask | undefined; + readonly handle: ITaskHandle | undefined; + readonly toInfoFn?: (base: AgentTaskInfoBase) => AgentTaskInfo; + readonly forceStopFn?: () => Promise; + readonly onDetachFn?: () => void; + readonly outputChunks: string[]; + outputSizeBytes: number; + retainedOutputBytes: number; + outputLimitTripped: boolean; + status: AgentTaskStatus; + options: RegisterAgentTaskOptions & { description?: string }; + readonly startedAt: number; + endedAt: number | null; + foregroundRelease?: ForegroundRelease; + stopReason?: string; + terminalNotificationSuppressed?: boolean; + terminalFired: boolean; + readonly abortController: AbortController; + foregroundSignalCleanup?: () => void; + lifecyclePromise: Promise; + persistWriteQueue: Promise; + outputWriteQueue: Promise; + pendingOutput: string[]; + pendingOutputBytes: number; + outputPersistStarted: boolean; + timeoutHandle?: ReturnType; + timedOut: boolean; + readonly waiters: Array<() => void>; + handleSubscription?: { dispose(): void }; +} + +const MAX_OUTPUT_BYTES = 1024 * 1024; + +const TERMINAL_OUTPUT_TAIL_BYTES = 4 * 1024; + +const MAX_TASK_OUTPUT_BYTES = 16 * 1024 * 1024; + +function outputLimitReason(): string { + const mib = Math.floor(MAX_TASK_OUTPUT_BYTES / (1024 * 1024)); + return ( + `Output limit exceeded: the command produced more than ${mib} MiB and was ` + + 'terminated. Redirect large output to a file (e.g. `command > out.txt`) and ' + + 'inspect it in slices instead.' + ); +} + +const SIGTERM_GRACE_MS = 5_000; +const TASK_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; +const SESSION_CLOSED_REASON = 'Session closed'; +const NOTIFICATION_FALLBACK_PREVIEW_BYTES = 3_000; +const QUESTION_ANSWER_INLINE_BYTES = 16_000; +const ACTIVE_BACKGROUND_TASK_INJECTION_VARIANT = 'background_task_status'; +const TASK_RESUME_TERMINATION_VARIANT = 'task_resume_termination'; +const ACTIVE_BACKGROUND_TASK_GUIDANCE = [ + 'The conversation was compacted, so the earlier messages that started these background tasks are gone — but the tasks are still running from before.', + 'Do not start duplicates. Use TaskList to list them, TaskOutput for a non-blocking status/output snapshot, and TaskStop to cancel one — completion arrives via automatic notification.', +].join(' '); + +export function isAgentTaskTerminal(status: AgentTaskStatus): boolean { + return TERMINAL_STATUSES.has(status); +} + +function coerceTimeoutSettlement( + entry: ManagedTask, + settlement: AgentTaskSettlement, +): AgentTaskSettlement { + if (entry.timedOut && settlement.status === 'killed') { + return { ...settlement, status: 'timed_out' }; + } + return settlement; +} + +export const taskGhostsKey = defineState>( + 'task.ghosts', + () => new Map(), +); +export const taskScheduledNotificationKeysKey = defineState>( + 'task.scheduledNotificationKeys', + () => new Set(), +); +export const taskDeliveredNotificationKeysKey = defineState>( + 'task.deliveredNotificationKeys', + () => new Set(), +); +export const taskActiveTaskReminderPendingKey = defineState( + 'task.activeTaskReminderPending', + () => false, +); + +export class AgentTaskService extends Disposable implements IAgentTaskService { + declare readonly _serviceBrand: undefined; + + private readonly tasks = new Map(); + private exitSuppressionArmed = false; + private readonly buildingNotificationKeys = new Set(); + private readonly pendingNotificationRequests = new Map(); + private readonly persistence: AgentTaskPersistence; + private notificationRestoreQueue: Promise = Promise.resolve(); + + constructor( + @ITelemetryService private readonly telemetry: ITelemetryService, + @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + @IConfigService private readonly config: IConfigService, + @IAtomicDocumentStore atomicDocs: IAtomicDocumentStore, + @IFileSystemStorageService byteStore: IFileSystemStorageService, + @ISessionContext session: ISessionContext, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @ITaskService private readonly taskService: ITaskService, + @IEventBus private readonly eventBus: IEventBus, + @ISessionEventBus private readonly sessionEventBus: ISessionEventBus, + @IEventDispatcher private readonly dispatcher: IEventDispatcher, + @IAgentReminderService private readonly reminder: IAgentReminderService, + @IAgentLoopService private readonly loop: IAgentLoopService, + @IAgentConversationUndoParticipantRegistry + undoParticipants: IAgentConversationUndoParticipantRegistry, + @ILogService private readonly log: ILogService, + @IAgentStateService private readonly states: IAgentStateService, + ) { + super(); + this.states.contributeState(taskKey); + this.states.contributeState(taskNotificationDeliveryKey); + this.states.contributeState(taskGhostsKey); + this.states.contributeState(taskScheduledNotificationKeysKey); + this.states.contributeState(taskDeliveredNotificationKeysKey); + this.states.contributeState(taskActiveTaskReminderPendingKey); + const fallbackRoot = + this.scopeContext.agentId === 'main' + ? { dir: session.sessionDir, scope: session.scope() } + : undefined; + this.persistence = new AgentTaskPersistence( + join(session.sessionDir, 'agents', this.scopeContext.agentId), + this.scopeContext.scope(), + atomicDocs, + byteStore, + fallbackRoot, + ); + this._register( + undoParticipants.register({ + id: 'task.notificationDelivery', + reconcileAfterUndo: () => this.reconcileNotificationDeliveryAfterUndo(), + }), + ); + this._register( + this.dispatcher.hooks.onDidRestore.register('task', async (_ctx, next) => { + for (const key of this.states.get(taskNotificationDeliveryKey)) { + this.deliveredNotificationKeys.add(key); + } + await this.restoreAfterReplay(); + await next(); + }), + ); + this._register( + this.eventBus.subscribe(ContextSpliced, (e) => { + if (isCompactionSplice(e)) { + this.activeTaskReminderPending = true; + } + for (const message of e.messages) { + if (isTaskOrigin(message.origin)) { + this.markDeliveredNotification(message.origin); + } + } + }), + ); + this._register( + this.reminder.register(ACTIVE_BACKGROUND_TASK_INJECTION_VARIANT, () => + this.activeBackgroundTaskReminder(), + ), + ); + } + + private get ghosts(): Map { + return this.states.get(taskGhostsKey); + } + + private get scheduledNotificationKeys(): Set { + return this.states.get(taskScheduledNotificationKeysKey); + } + + private get deliveredNotificationKeys(): Set { + 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 { + this.restoreGhostsFromWire(); + await this.loadFromDisk({ replace: false }); + await this.reconcile(); + } + + private activeBackgroundTaskReminder(): string | undefined { + if (!this.activeTaskReminderPending) return undefined; + this.activeTaskReminderPending = false; + const tasks = this.list(true); + if (tasks.length === 0) return undefined; + return `${ACTIVE_BACKGROUND_TASK_GUIDANCE}\n\n${formatTaskList(tasks, true)}`; + } + + private restoreGhostsFromWire(): void { + for (const [taskId, info] of this.states.get(taskKey)) { + if (this.tasks.has(taskId)) continue; + this.ghosts.set(taskId, info); + } + } + + registerTask(task: AgentTask, options: RegisterAgentTaskOptions = {}): string { + const detached = options.detached ?? true; + const timeoutMs = options.timeoutMs ?? task.timeoutMs; + const entryOptions: RegisterAgentTaskOptions = { + detached, + timeoutMs, + detachTimeoutMs: options.detachTimeoutMs, + autoBackgroundOnTimeout: options.autoBackgroundOnTimeout, + signal: detached ? undefined : options.signal, + }; + this.assertCanRegister(detached); + const entry: ManagedTask = { + taskId: generateTaskId(task.idPrefix), + task, + handle: undefined, + outputChunks: [], + outputSizeBytes: 0, + retainedOutputBytes: 0, + outputLimitTripped: false, + status: 'running', + options: entryOptions, + startedAt: Date.now(), + endedAt: null, + foregroundRelease: detached ? undefined : createForegroundRelease(), + abortController: new AbortController(), + lifecyclePromise: Promise.resolve(), + persistWriteQueue: Promise.resolve(), + outputWriteQueue: Promise.resolve(), + pendingOutput: [], + pendingOutputBytes: 0, + outputPersistStarted: detached, + waiters: [], + terminalFired: false, + timedOut: false, + }; + this.tasks.set(entry.taskId, entry); + this.ghosts.delete(entry.taskId); + + if (timeoutMs !== undefined && timeoutMs > 0) { + this.armManagerTimeout(entry, timeoutMs); + } + + entry.lifecyclePromise = Promise.resolve() + .then(() => + task.start({ + signal: entry.abortController.signal, + appendOutput: (chunk) => { + this.appendOutput(entry, chunk); + }, + settle: (settlement) => + this.settleTask(entry, coerceTimeoutSettlement(entry, settlement)), + }), + ) + .catch(async (error: unknown) => { + const aborted = entry.abortController.signal.aborted; + let status: AgentTaskStatus; + if (entry.timedOut) { + status = 'timed_out'; + } else if (aborted) { + status = 'killed'; + } else { + status = 'failed'; + } + await this.settleTask(entry, { + status, + stopReason: status === 'failed' ? errorMessage(error) : undefined, + }); + }); + this.installForegroundSignal(entry); + + if (this.isDetached(entry)) { + void this.persistLive(entry); + this.recordTaskStarted(this.toInfo(entry)); + } + return entry.taskId; + } + + track(handle: ITaskHandle, options: AgentTaskTrackOptions): IAgentTaskEntry { + const detached = options.detached ?? true; + this.assertCanRegister(detached); + + const taskId = generateTaskId(options.idPrefix ?? 'task'); + const timeoutMs = options.timeoutMs; + + const entry: ManagedTask = { + taskId, + task: undefined, + handle, + toInfoFn: options.toInfo, + forceStopFn: options.forceStop, + onDetachFn: options.onDetach, + outputChunks: [], + outputSizeBytes: 0, + retainedOutputBytes: 0, + outputLimitTripped: false, + status: 'running', + options: { detached, timeoutMs, detachTimeoutMs: options.detachTimeoutMs, signal: detached ? undefined : options.signal, description: options.description }, + startedAt: Date.now(), + endedAt: null, + foregroundRelease: detached ? undefined : createForegroundRelease(), + abortController: new AbortController(), + lifecyclePromise: Promise.resolve(), + persistWriteQueue: Promise.resolve(), + outputWriteQueue: Promise.resolve(), + pendingOutput: [], + pendingOutputBytes: 0, + outputPersistStarted: detached, + waiters: [], + terminalFired: false, + timedOut: false, + }; + this.tasks.set(taskId, entry); + this.ghosts.delete(taskId); + + if (timeoutMs !== undefined && timeoutMs > 0) { + this.armManagerTimeout(entry, timeoutMs); + } + + const outputSub = handle.onDidOutput((chunk) => { + this.appendOutput(entry, chunk); + }); + + const stateSub = handle.onDidChangeState((state) => { + if (!TERMINAL_TASK_STATES.has(state)) return; + const status = entry.timedOut ? 'timed_out' as const + : state === 'cancelled' ? 'killed' as const + : state === 'failed' ? 'failed' as const + : 'completed' as const; + void this.settleTask(entry, { status, stopReason: entry.stopReason }); + }); + + entry.handleSubscription = { + dispose() { + outputSub.dispose(); + stateSub.dispose(); + }, + }; + + entry.lifecyclePromise = handle.result.then(() => { }, () => { }); + + this.installForegroundSignal(entry); + + if (this.isDetached(entry)) { + void this.persistLive(entry); + this.recordTaskStarted(this.toInfo(entry)); + } + + return { + taskId, + onDidDetach: entry.foregroundRelease?.promise ?? Promise.resolve('terminal' as const), + }; + } + + getTask(taskId: string): AgentTaskInfo | undefined { + const entry = this.tasks.get(taskId); + return entry === undefined ? this.ghosts.get(taskId) : this.toInfo(entry); + } + + list(activeOnly = true, limit?: number): readonly AgentTaskInfo[] { + const result: AgentTaskInfo[] = []; + for (const entry of this.tasks.values()) { + const info = this.toInfo(entry); + if (!shouldListTask(info, activeOnly)) continue; + result.push(info); + if (limit !== undefined && result.length >= limit) return result; + } + if (!activeOnly) { + for (const ghost of this.ghosts.values()) { + if (!shouldListTask(ghost, activeOnly)) continue; + result.push(ghost); + if (limit !== undefined && result.length >= limit) return result; + } + } + return result; + } + + private async reconcileNotificationDeliveryAfterUndo(): Promise { + const restoredKeys = new Set(this.states.get(taskNotificationDeliveryKey)); + for (const [key, request] of this.pendingNotificationRequests) { + if (request.dropped) this.clearPendingNotification(key, request); + } + this.deliveredNotificationKeys.clear(); + for (const key of restoredKeys) this.deliveredNotificationKeys.add(key); + for (const key of this.scheduledNotificationKeys) { + if (restoredKeys.has(key) || !this.pendingNotificationRequests.has(key)) { + this.scheduledNotificationKeys.delete(key); + } + } + await this.restoreAgentTaskNotifications(); + } + + persistOutput(taskId: string): void { + const entry = this.tasks.get(taskId); + if (entry === undefined) return; + this.startOutputPersist(entry); + } + + async loadFromDisk(options: AgentTaskLoadOptions = {}): Promise { + const persistence = this.persistence; + if (options.replace !== false) { + this.ghosts.clear(); + } + const tasks = await persistence.listTasks(); + for (const task of tasks) { + if (this.tasks.has(task.taskId)) continue; + const existing = this.ghosts.get(task.taskId); + if (existing !== undefined) { + this.ghosts.set(task.taskId, newerRestoredTask(existing, task)); + continue; + } + this.ghosts.set(task.taskId, task); + } + } + + async reconcile(): Promise { + const lostTasks = await this.markLoadedTasksLost(); + for (const info of lostTasks) { + this.recordTaskTerminated(info); + } + this.appendPreviousSessionTasksReminder(); + await this.restoreAgentTaskNotifications(); + return lostTasks; + } + + async getOutputSnapshot( + taskId: string, + maxPreviewBytes: number, + ): Promise { + if (this.getTask(taskId) === undefined) return emptyOutputSnapshot(); + + await this.tasks.get(taskId)?.outputWriteQueue; + + const previewLimit = Math.max(0, Math.trunc(maxPreviewBytes)); + const persistence = this.persistence; + const persisted = await persistence.readTaskOutputSnapshot(taskId, previewLimit); + if (persisted !== undefined) { + return { + ...persisted, + fullOutputAvailable: true, + }; + } + + const entry = this.tasks.get(taskId); + if (entry === undefined) return emptyOutputSnapshot(); + + const available = Buffer.from(entry.outputChunks.join(''), 'utf-8'); + const previewBytes = Math.min(previewLimit, available.byteLength, entry.outputSizeBytes); + const previewOffset = Math.max(0, available.byteLength - previewBytes); + return { + outputSizeBytes: entry.outputSizeBytes, + previewBytes, + truncated: entry.outputSizeBytes > previewBytes, + fullOutputAvailable: false, + preview: available.subarray(previewOffset).toString('utf-8'), + }; + } + + async readOutput(taskId: string, tail?: number): Promise { + const output = (await this.getOutputSnapshot(taskId, Number.MAX_SAFE_INTEGER)).preview; + if (tail === undefined) return output; + return output.slice(-Math.max(0, Math.trunc(tail))); + } + + async suppressTerminalNotification(taskId: string): Promise { + const entry = this.tasks.get(taskId); + if (entry !== undefined) { + if (entry.terminalNotificationSuppressed === true) return; + entry.terminalNotificationSuppressed = true; + await this.persistLive(entry); + return; + } + + const ghost = this.ghosts.get(taskId); + if (ghost !== undefined) return; + } + + markTasksDeliveredViaWait(tasks: readonly AgentTaskWaitDelivery[]): void { + if (tasks.length === 0) return; + const keys: string[] = []; + for (const { taskId, status } of tasks) { + const origin: TaskNotificationOrigin = { + taskId, + status, + notificationId: taskNotificationId(taskId, status), + }; + const key = notificationKey(origin); + this.pendingNotificationRequests.get(key)?.drop(); + this.markDeliveredNotification(origin); + keys.push(key); + } + void this.dispatcher.dispatch( + new TaskWaitDelivered({ agentId: this.scopeContext.agentId, keys }), + ); + } + + detach(taskId: string): AgentTaskInfo | undefined { + const entry = this.tasks.get(taskId); + if (entry === undefined) return this.ghosts.get(taskId); + return this.detachEntry(entry, false); + } + + private detachEntry(entry: ManagedTask, viaTimeout: boolean): AgentTaskInfo | undefined { + if (TERMINAL_STATUSES.has(entry.status)) return this.toInfo(entry); + + const foregroundRelease = entry.foregroundRelease; + if (foregroundRelease === undefined) return this.toInfo(entry); + + entry.foregroundRelease = undefined; + entry.foregroundSignalCleanup?.(); + entry.foregroundSignalCleanup = undefined; + this.applyDetachTimeout(entry); + try { + const onDetach = + entry.onDetachFn ?? + (entry.task === undefined ? undefined : entry.task.onDetach?.bind(entry.task)); + onDetach?.(); + } catch { + } + this.startOutputPersist(entry); + void this.persistLive(entry); + this.recordTaskStarted(this.toInfo(entry)); + foregroundRelease.resolve(viaTimeout ? 'timeout_detached' : 'detached'); + return this.toInfo(entry); + } + + private applyDetachTimeout(entry: ManagedTask): void { + const timeoutMs = entry.options.detachTimeoutMs; + if (timeoutMs === undefined) return; + entry.options = { ...entry.options, timeoutMs }; + if (entry.timeoutHandle !== undefined) { + clearTimeout(entry.timeoutHandle); + entry.timeoutHandle = undefined; + } + if (timeoutMs > 0) { + this.armManagerTimeout(entry, timeoutMs); + } + } + + private armManagerTimeout(entry: ManagedTask, timeoutMs: number): void { + entry.timeoutHandle = setClampedTimeout(() => { + entry.timeoutHandle = undefined; + if (this.canAutoBackgroundOnTimeout(entry)) { + this.detachEntry(entry, true); + return; + } + void this.terminateWithGrace(entry, { + abortReason: 'Timed out', + finalStatus: 'timed_out', + }); + }, timeoutMs); + entry.timeoutHandle.unref?.(); + } + + private canAutoBackgroundOnTimeout(entry: ManagedTask): boolean { + return entry.options.autoBackgroundOnTimeout === true && !this.isDetached(entry); + } + + async stop(taskId: string, reason?: string): Promise { + const entry = this.tasks.get(taskId); + if (entry === undefined) return undefined; + const normalized = normalizeReason(reason); + return this.terminateWithGrace(entry, { + stopReason: normalized, + abortReason: normalized, + finalStatus: 'killed', + }); + } + + async stopByUser(taskId: string): Promise { + const entry = this.tasks.get(taskId); + if (entry === undefined) return undefined; + const reason = userCancellationReason(); + return this.terminateWithGrace(entry, { + stopReason: reason.message, + abortReason: reason, + finalStatus: 'killed', + }); + } + + private async terminateWithGrace( + entry: ManagedTask, + options: { + readonly stopReason?: string; + readonly abortReason: unknown; + readonly finalStatus: 'killed' | 'timed_out'; + }, + ): Promise { + if (TERMINAL_STATUSES.has(entry.status)) { + await entry.persistWriteQueue; + return this.toInfo(entry); + } + + if (entry.timeoutHandle !== undefined) { + clearTimeout(entry.timeoutHandle); + entry.timeoutHandle = undefined; + } + if (options.finalStatus === 'timed_out') { + entry.timedOut = true; + } + entry.stopReason = options.stopReason; + if (entry.handle) { + entry.handle.cancel(); + } else { + entry.abortController.abort(options.abortReason); + } + + const graceMs = resolveAgentTaskConfig(this.config)?.killGracePeriodMs ?? SIGTERM_GRACE_MS; + let graceTimer: ReturnType | undefined; + const graceful = await Promise.race([ + entry.lifecyclePromise.then( + () => true, + () => true, + ), + new Promise((resolve) => { + graceTimer = setTimeout(() => { + resolve(false); + }, graceMs); + graceTimer.unref?.(); + }), + ]); + if (graceTimer !== undefined) clearTimeout(graceTimer); + + if (TERMINAL_STATUSES.has(entry.status)) { + await entry.persistWriteQueue; + return this.toInfo(entry); + } + + if (!graceful) { + try { + const forceStop = + entry.forceStopFn ?? + (entry.task === undefined ? undefined : entry.task.forceStop?.bind(entry.task)); + await forceStop?.(); + } catch { + } + } + + if (TERMINAL_STATUSES.has(entry.status)) { + await entry.persistWriteQueue; + return this.toInfo(entry); + } + + await this.settleTask(entry, { + status: options.finalStatus, + stopReason: options.stopReason, + }); + await entry.persistWriteQueue; + return this.toInfo(entry); + } + + async stopAll(reason?: string): Promise { + const results = await Promise.all( + Array.from(this.tasks.keys()).map((taskId) => this.stop(taskId, reason)), + ); + return results.filter((info): info is AgentTaskInfo => info !== undefined); + } + + async suppressAllTerminalNotifications(): Promise { + this.exitSuppressionArmed = true; + for (const [, request] of Array.from(this.pendingNotificationRequests)) { + request.drop(); + } + } + + async stopAllOnExit(reason: string): Promise { + await this.suppressAllTerminalNotifications(); + if (this.keepAliveOnExit()) return []; + return this.stopAll(reason); + } + + override dispose(): void { + if (!this.keepAliveOnExit()) { + for (const entry of this.tasks.values()) { + if (TERMINAL_STATUSES.has(entry.status)) continue; + if (entry.timeoutHandle !== undefined) { + clearTimeout(entry.timeoutHandle); + entry.timeoutHandle = undefined; + } + if (entry.handle !== undefined) { + entry.handle.cancel(); + } else { + entry.abortController.abort(SESSION_CLOSED_REASON); + } + this.forceStopOnDispose(entry); + } + } + super.dispose(); + } + + private forceStopOnDispose(entry: ManagedTask): void { + const forceStop = + entry.forceStopFn ?? + (entry.task === undefined ? undefined : entry.task.forceStop?.bind(entry.task)); + if (forceStop === undefined) return; + try { + void forceStop().catch(() => {}); + } catch {} + } + + private keepAliveOnExit(): boolean { + return resolveAgentTaskConfig(this.config)?.keepAliveOnExit === true; + } + + private lifecycleActive(): boolean { + return this.sessionEventBus.isAgentActive(this.scopeContext.agentContext); + } + + private marksTerminalNotificationSuppressed(entry: ManagedTask): boolean { + return this.exitSuppressionArmed && !this.keepAliveOnExit() && this.isDetached(entry); + } + + async wait( + taskId: string, + timeoutMs = 30_000, + signal?: AbortSignal, + ): Promise { + const entry = this.tasks.get(taskId); + if (entry === undefined) return this.ghosts.get(taskId); + if (TERMINAL_STATUSES.has(entry.status)) { + await entry.persistWriteQueue; + return this.toInfo(entry); + } + if (timeoutMs <= 0) { + return this.toInfo(entry); + } + + let waiter: (() => void) | undefined; + let timeout: ReturnType | undefined; + try { + const pending = Promise.race([ + new Promise((resolve) => { + waiter = resolve; + entry.waiters.push(resolve); + }), + new Promise((resolve) => { + timeout = setClampedTimeout(resolve, timeoutMs); + timeout.unref?.(); + }), + ]); + await (signal === undefined ? pending : abortable(pending, signal)); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + if (waiter !== undefined) { + const index = entry.waiters.indexOf(waiter); + if (index !== -1) entry.waiters.splice(index, 1); + } + } + + if (TERMINAL_STATUSES.has(entry.status)) { + await entry.persistWriteQueue; + } + return this.toInfo(entry); + } + + async waitForForegroundRelease( + taskId: string, + ): Promise { + const entry = this.tasks.get(taskId); + if (entry === undefined) return undefined; + if (TERMINAL_STATUSES.has(entry.status)) { + await entry.persistWriteQueue; + return 'terminal'; + } + if (this.isDetached(entry)) return 'detached'; + + const foregroundRelease = entry.foregroundRelease; + if (foregroundRelease === undefined) return 'detached'; + const foregroundReleasePromise = foregroundRelease.promise; + const reason = await Promise.race([ + foregroundReleasePromise, + entry.lifecyclePromise.then(() => 'terminal' as const), + ]); + if (reason === 'terminal') { + await entry.persistWriteQueue; + } + return reason; + } + + private assertCanRegister(detached: boolean): void { + const maxRunningTasks = resolveAgentTaskConfig(this.config)?.maxRunningTasks; + if (maxRunningTasks === undefined) return; + if (!detached) return; + if (this.activeTaskCount() < maxRunningTasks) return; + throw new Error2(ErrorCodes.TASK_LIMIT_EXCEEDED, 'Too many background tasks are already running.', { + details: { running: this.activeTaskCount(), max: maxRunningTasks }, + }); + } + + private activeTaskCount(): number { + let count = 0; + for (const entry of this.tasks.values()) { + if (!TERMINAL_STATUSES.has(entry.status) && this.startsDetached(entry)) count++; + } + return count; + } + + private startsDetached(entry: ManagedTask): boolean { + return entry.options.detached !== false; + } + + private isDetached(entry: ManagedTask): boolean { + return entry.foregroundRelease === undefined; + } + + private async markLoadedTasksLost(): Promise { + const lostTasks: AgentTaskInfo[] = []; + const persistence = this.persistence; + for (const [taskId, info] of this.ghosts) { + if (TERMINAL_STATUSES.has(info.status)) continue; + const updated: AgentTaskInfo = { + ...info, + status: 'lost', + endedAt: info.endedAt ?? Date.now(), + }; + this.ghosts.set(taskId, updated); + await persistence.writeTask(updated); + lostTasks.push(updated); + } + return lostTasks; + } + + private persistLive(entry: ManagedTask): Promise { + const persistence = this.persistence; + const info = this.toInfo(entry); + entry.persistWriteQueue = entry.persistWriteQueue + .then(() => persistence.writeTask(info)) + .catch(() => { }); + return entry.persistWriteQueue; + } + + private appendOutput(entry: ManagedTask, chunk: string): void { + const chunkBytes = Buffer.byteLength(chunk, 'utf-8'); + entry.outputSizeBytes += chunkBytes; + this.appendRetainedOutput(entry, chunk, chunkBytes); + + if ( + !entry.outputLimitTripped && + entry.task?.kind === 'process' && + entry.outputSizeBytes > MAX_TASK_OUTPUT_BYTES + ) { + entry.outputLimitTripped = true; + void this.stop(entry.taskId, outputLimitReason()); + } + + if (entry.outputLimitTripped) return; + + if (!entry.outputPersistStarted) { + entry.pendingOutput.push(chunk); + entry.pendingOutputBytes += chunkBytes; + if (entry.pendingOutputBytes > MAX_OUTPUT_BYTES) { + this.startOutputPersist(entry); + } + return; + } + this.appendTaskOutput(entry, chunk); + } + + private appendTaskOutput(entry: ManagedTask, chunk: string): void { + const persistence = this.persistence; + entry.outputWriteQueue = entry.outputWriteQueue + .then(() => persistence.appendTaskOutput(entry.taskId, chunk)) + .catch(() => { }); + } + + private startOutputPersist(entry: ManagedTask): void { + if (entry.outputPersistStarted) return; + entry.outputPersistStarted = true; + if (entry.pendingOutput.length > 0) { + this.appendTaskOutput(entry, entry.pendingOutput.join('')); + } + entry.pendingOutput = []; + entry.pendingOutputBytes = 0; + } + + private appendRetainedOutput(entry: ManagedTask, chunk: string, chunkBytes: number): void { + if (chunkBytes >= MAX_OUTPUT_BYTES) { + const retained = Buffer.from(chunk, 'utf-8') + .subarray(chunkBytes - MAX_OUTPUT_BYTES) + .toString('utf-8'); + entry.outputChunks.length = 0; + entry.outputChunks.push(retained); + entry.retainedOutputBytes = Buffer.byteLength(retained, 'utf-8'); + return; + } + + entry.outputChunks.push(chunk); + entry.retainedOutputBytes += chunkBytes; + while (entry.retainedOutputBytes > MAX_OUTPUT_BYTES) { + const removed = entry.outputChunks.shift(); + if (removed === undefined) break; + entry.retainedOutputBytes -= Buffer.byteLength(removed, 'utf-8'); + } + } + + private async settleTask( + entry: ManagedTask, + settlement: AgentTaskSettlement, + ): Promise { + if (TERMINAL_STATUSES.has(entry.status)) return false; + entry.status = settlement.status; + entry.endedAt = Date.now(); + entry.stopReason = + settlement.stopReason ?? (settlement.status === 'killed' ? entry.stopReason : undefined); + entry.foregroundSignalCleanup?.(); + entry.foregroundSignalCleanup = undefined; + entry.handleSubscription?.dispose(); + entry.handleSubscription = undefined; + if (entry.timeoutHandle !== undefined) { + clearTimeout(entry.timeoutHandle); + entry.timeoutHandle = undefined; + } + const foregroundRelease = entry.foregroundRelease; + if (this.marksTerminalNotificationSuppressed(entry)) { + entry.terminalNotificationSuppressed = true; + } + if (entry.outputPersistStarted) { + await this.persistLive(entry); + } else { + entry.pendingOutput = []; + entry.pendingOutputBytes = 0; + } + if ( + this.marksTerminalNotificationSuppressed(entry) && + entry.terminalNotificationSuppressed !== true + ) { + entry.terminalNotificationSuppressed = true; + await this.persistLive(entry); + } + this.fireTerminalEffects(entry); + foregroundRelease?.resolve('terminal'); + this.resolveWaiters(entry); + return true; + } + + private fireTerminalEffects(entry: ManagedTask): void { + if (entry.terminalFired) return; + if (!this.isDetached(entry)) return; + entry.terminalFired = true; + const info = this.toInfo(entry); + void this.notifyAgentTask(info).catch((error) => { + this.log.error('task notification delivery failed', { taskId: info.taskId, error }); + }); + this.recordTaskTerminated(info, this.retainedOutputTail(entry)); + } + + private retainedOutputTail(entry: ManagedTask): string | undefined { + if (entry.outputChunks.length === 0) return undefined; + const retained = Buffer.from(entry.outputChunks.join(''), 'utf-8'); + const offset = Math.max(0, retained.byteLength - TERMINAL_OUTPUT_TAIL_BYTES); + return retained.subarray(offset).toString('utf-8'); + } + + private recordTaskStarted(info: AgentTaskInfo): void { + if (this.lifecycleActive()) { + void this.dispatcher.dispatch( + new TaskStarted({ agentId: this.scopeContext.agentId, info }), + ); + } + this.telemetry.track2('background_task_created', { + task_id: info.taskId, + kind: info.kind === 'process' ? 'bash' : info.kind, + }); + } + + private recordTaskTerminated(info: AgentTaskInfo, outputTail?: string): void { + if (this.lifecycleActive()) { + void this.dispatcher.dispatch( + new TaskTerminated({ agentId: this.scopeContext.agentId, info, outputTail }), + ); + } + this.telemetry.track2('background_task_completed', { + task_id: info.taskId, + kind: info.kind, + duration_ms: info.endedAt !== null ? info.endedAt - info.startedAt : null, + status: info.status, + }); + } + + private async notifyAgentTask(info: AgentTaskInfo): Promise { + if (!this.lifecycleActive()) return; + const context = await this.buildAgentTaskNotificationContext(info); + if (context === undefined) return; + if (!this.lifecycleActive() || this.isTerminalNotificationSuppressed(info.taskId)) return; + const key = notificationKey(context.origin); + if (this.deliveredNotificationKeys.has(key)) return; + const handle = this.loop.notify({ + message: { + role: 'user', + content: [...context.content], + toolCalls: [], + origin: context.origin, + }, + turnScoped: false, + onConsume: () => { + this.pendingNotificationRequests.delete(key); + this.fireNotificationHook(context.notification); + }, + onDrop: () => this.clearPendingNotification(key, handle), + }); + this.pendingNotificationRequests.set(key, handle); + } + + private restoreAgentTaskNotifications(): Promise { + const restore = this.notificationRestoreQueue.then(() => + this.restoreAgentTaskNotificationsNow(), + ); + this.notificationRestoreQueue = restore.catch(() => {}); + return restore; + } + + private async restoreAgentTaskNotificationsNow(): Promise { + for (const info of this.list(false)) { + if (!isAgentTaskTerminal(info.status)) continue; + if (info.status === 'lost') continue; + await this.restoreAgentTaskNotification(info); + } + } + + private appendPreviousSessionTasksReminder(): void { + const tasks: AgentTaskInfo[] = []; + for (const info of this.ghosts.values()) { + if (info.resumeReminded === true) continue; + if (!isPreviousSessionTermination(info)) continue; + if ( + this.hasPreviousSessionReminder(info.taskId) || + (info.status === 'lost' && this.hasDeliveredTaskOrigin(info)) + ) { + this.persistPreviousSessionReminderMarker(info); + continue; + } + tasks.push(info); + } + if (tasks.length === 0) return; + const lines = tasks.map((info) => previousSessionTaskLine(info)); + this.reminder.notify( + [ + 'The user exited the application after your last turn, so your background tasks from the previous session lost contact:', + ...lines, + "Don't assume any of them completed; check current state (they may still be running), then re-run or resume only what you still need.", + ].join('\n'), + { variant: TASK_RESUME_TERMINATION_VARIANT }, + ); + for (const info of tasks) { + this.firePreviousSessionLostTaskNotificationHook(info); + this.persistPreviousSessionReminderMarker(info); + } + } + + private hasPreviousSessionReminder(taskId: string): boolean { + const taskLinePrefix = `- ${taskId} "`; + return this.context.get().some((message) => { + if ( + message.origin?.kind !== 'injection' || + message.origin.variant !== TASK_RESUME_TERMINATION_VARIANT + ) { + return false; + } + return message.content.some( + (part) => + part.type === 'text' && + part.text.split('\n').some((line) => line.startsWith(taskLinePrefix)), + ); + }); + } + + private hasDeliveredTaskOrigin(info: AgentTaskInfo): boolean { + const origin: TaskNotificationOrigin = { + taskId: info.taskId, + status: info.status, + notificationId: taskNotificationId(info.taskId, info.status), + }; + const key = notificationKey(origin); + return ( + this.states.get(taskNotificationDeliveryKey).includes(key) || + this.deliveredNotificationKeys.has(key) || + this.hasDeliveredNotification(key) + ); + } + + private persistPreviousSessionReminderMarker(info: AgentTaskInfo): void { + const marked: AgentTaskInfo = { ...info, resumeReminded: true }; + this.ghosts.set(info.taskId, marked); + void this.persistence.writeTask(marked).catch((error: unknown) => { + this.log.error('previous-session task reminder marker write failed', { + taskId: info.taskId, + error, + }); + }); + } + + private firePreviousSessionLostTaskNotificationHook(info: AgentTaskInfo): void { + if (info.status !== 'lost') return; + if (info.detached === false) return; + if (info.terminalNotificationSuppressed === true) return; + const origin: TaskNotificationOrigin = { + taskId: info.taskId, + status: info.status, + notificationId: taskNotificationId(info.taskId, info.status), + }; + const key = notificationKey(origin); + if (this.scheduledNotificationKeys.has(key)) return; + if (this.deliveredNotificationKeys.has(key)) return; + if (this.hasDeliveredNotification(key)) return; + this.fireNotificationHook(buildAgentTaskNotification(info)); + } + + private async restoreAgentTaskNotification(info: AgentTaskInfo): Promise { + const context = await this.buildAgentTaskNotificationContext(info); + if (context === undefined) return; + this.context.append({ + role: 'user', + content: [...context.content], + toolCalls: [], + origin: context.origin, + }); + this.fireNotificationHook(context.notification); + } + + private async buildAgentTaskNotificationContext( + info: AgentTaskInfo, + ): Promise { + if (info.detached === false) return undefined; + if (info.terminalNotificationSuppressed === true) return undefined; + const origin: TaskOrigin = { + kind: 'task', + taskId: info.taskId, + status: info.status, + notificationId: taskNotificationId(info.taskId, info.status), + }; + const key = notificationKey(origin); + if (this.buildingNotificationKeys.has(key)) return undefined; + if (this.scheduledNotificationKeys.has(key)) return undefined; + if (this.deliveredNotificationKeys.has(key)) return undefined; + if (this.hasDeliveredNotification(key)) return undefined; + this.buildingNotificationKeys.add(key); + try { + let output = emptyOutputSnapshot(); + try { + output = await this.notificationOutputSnapshot(info); + } catch (error) { + this.log.error('task notification output read failed; delivering without output', { + taskId: info.taskId, + error, + }); + } + if (this.isTerminalNotificationSuppressed(info.taskId)) return undefined; + if (this.scheduledNotificationKeys.has(key)) return undefined; + if (this.deliveredNotificationKeys.has(key)) return undefined; + if (this.hasDeliveredNotification(key)) return undefined; + this.scheduledNotificationKeys.add(key); + const notification = buildAgentTaskNotification(info, output); + const content = [ + { + type: 'text', + text: renderNotificationXml(notification), + }, + ] as const; + return { content, origin, notification }; + } finally { + this.buildingNotificationKeys.delete(key); + } + } + + private async notificationOutputSnapshot(info: AgentTaskInfo): Promise { + if (info.kind === 'question') { + return this.getOutputSnapshot(info.taskId, QUESTION_ANSWER_INLINE_BYTES); + } + const persisted = await this.getOutputSnapshot(info.taskId, 0); + if (persisted.fullOutputAvailable) return persisted; + return this.getOutputSnapshot(info.taskId, NOTIFICATION_FALLBACK_PREVIEW_BYTES); + } + + private fireNotificationHook(notification: AgentTaskNotification): void { + if (!this.lifecycleActive()) return; + void this.dispatcher.dispatch( + new TaskNotified({ + agentId: this.scopeContext.agentId, + notificationType: notification.type, + title: notification.title, + body: notification.body, + severity: notification.severity, + sourceKind: notification.source_kind, + sourceId: notification.source_id, + }), + ); + } + + private isTerminalNotificationSuppressed(taskId: string): boolean { + return ( + this.exitSuppressionArmed || + this.tasks.get(taskId)?.terminalNotificationSuppressed === true || + this.ghosts.get(taskId)?.terminalNotificationSuppressed === true + ); + } + + private markDeliveredNotification(origin: TaskNotificationOrigin): void { + const key = notificationKey(origin); + this.scheduledNotificationKeys.delete(key); + this.pendingNotificationRequests.delete(key); + this.deliveredNotificationKeys.add(key); + } + + private clearPendingNotification(key: string, request: LoopNotifyHandle): void { + if (this.pendingNotificationRequests.get(key) !== request) return; + this.pendingNotificationRequests.delete(key); + if (!this.deliveredNotificationKeys.has(key) && !this.hasDeliveredNotification(key)) { + this.scheduledNotificationKeys.delete(key); + } + } + + private hasDeliveredNotification(key: string): boolean { + return this.context.get().some((message) => { + return isTaskOrigin(message.origin) && notificationKey(message.origin) === key; + }); + } + + private resolveWaiters(entry: ManagedTask): void { + const waiters = entry.waiters.splice(0); + for (const resolve of waiters) resolve(); + } + + private installForegroundSignal(entry: ManagedTask): void { + const signal = entry.options.signal; + if (signal === undefined) return; + + const abortFromSignal = (): void => { + if (this.isDetached(entry)) return; + const userReason = userCancellationReason(); + void this.terminateWithGrace(entry, { + stopReason: userReason.message, + abortReason: signal.reason, + finalStatus: 'killed', + }); + }; + if (signal.aborted) { + abortFromSignal(); + return; + } + signal.addEventListener('abort', abortFromSignal, { once: true }); + entry.foregroundSignalCleanup = () => { + signal.removeEventListener('abort', abortFromSignal); + }; + } + + private toInfo(entry: ManagedTask): AgentTaskInfo { + const base: AgentTaskInfoBase = { + taskId: entry.taskId, + description: entry.task?.description ?? entry.options.description ?? '', + status: entry.status, + detached: this.isDetached(entry) ? true : false, + startedAt: entry.startedAt, + endedAt: entry.endedAt, + stopReason: entry.stopReason, + terminalNotificationSuppressed: entry.terminalNotificationSuppressed, + timeoutMs: entry.options.timeoutMs, + }; + if (entry.toInfoFn) return entry.toInfoFn(base); + return entry.task!.toInfo(base); + } +} + +function emptyOutputSnapshot(): AgentTaskOutputSnapshot { + return { + outputSizeBytes: 0, + previewBytes: 0, + truncated: false, + fullOutputAvailable: false, + preview: '', + }; +} + +function agentTaskNotificationChildren( + info: AgentTaskInfo, + output: AgentTaskOutputSnapshot | undefined, +): readonly string[] | undefined { + if (output === undefined) return undefined; + if (inlinesQuestionAnswer(info, output)) { + return output.preview.length === 0 ? undefined : [renderAnswerBlock(output.preview)]; + } + if (output.fullOutputAvailable && output.outputPath !== undefined) { + return [renderOutputFileBlock(output.outputPath, output.outputSizeBytes)]; + } + if (output.preview.length === 0) return undefined; + return [renderOutputPreviewBlock(output)]; +} + +function inlinesQuestionAnswer(info: AgentTaskInfo, output: AgentTaskOutputSnapshot): boolean { + return info.kind === 'question' && !output.truncated; +} + +function renderAnswerBlock(answer: string): string { + return ['', escapeXmlTags(answer), ''].join('\n'); +} + +function questionNotificationText( + info: AgentTaskInfo, + output: AgentTaskOutputSnapshot | undefined, +): { readonly title: string; readonly body: string } | undefined { + if (info.status !== 'completed' || output === undefined || !inlinesQuestionAnswer(info, output)) { + return undefined; + } + const outcome = questionOutcome(output.preview); + if (outcome === 'answered') { + return { + title: 'Background question answered', + body: `The user answered "${info.description}".`, + }; + } + if (outcome === 'dismissed') { + return { + title: 'Background question dismissed', + body: `The user dismissed "${info.description}" without answering.`, + }; + } + return undefined; +} + +function questionOutcome(output: string): 'answered' | 'dismissed' | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(output); + } catch { + return undefined; + } + if (typeof parsed !== 'object' || parsed === null) return undefined; + const answers = (parsed as { readonly answers?: unknown }).answers; + if (typeof answers !== 'object' || answers === null || Array.isArray(answers)) return undefined; + return Object.keys(answers).length > 0 ? 'answered' : 'dismissed'; +} + +function renderOutputFileBlock(outputPath: string, outputSizeBytes: number): string { + return [ + ``, + `Read the output file to retrieve the result: ${escapeXml(outputPath)}`, + '', + ].join('\n'); +} + +function renderOutputPreviewBlock(output: AgentTaskOutputSnapshot): string { + return [ + ``, + output.truncated + ? `Showing the last ${String(output.previewBytes)} bytes. No persisted full output is available.` + : 'No persisted full output is available; this preview is the currently buffered task output.', + escapeXml(output.preview), + '', + ].join('\n'); +} + +function shouldListTask(info: AgentTaskInfo, activeOnly: boolean): boolean { + if (!TERMINAL_STATUSES.has(info.status)) return true; + if (activeOnly) return false; + return info.detached !== false; +} + +function isCompactionSplice(splice: { + readonly deleteCount: number; + readonly messages: readonly { readonly origin?: { readonly kind: string } | undefined }[]; +}): boolean { + return ( + splice.deleteCount > 0 && + splice.messages.some((message) => message.origin?.kind === 'compaction_summary') + ); +} + +function newerRestoredTask( + existing: AgentTaskInfo, + loaded: AgentTaskInfo, +): AgentTaskInfo { + const existingTerminal = isAgentTaskTerminal(existing.status); + const loadedTerminal = isAgentTaskTerminal(loaded.status); + if (existingTerminal && !loadedTerminal) return existing; + if (!existingTerminal && loadedTerminal) return loaded; + if (existing.endedAt !== null && loaded.endedAt !== null) { + return loaded.endedAt >= existing.endedAt ? loaded : existing; + } + if (existing.endedAt !== null) return existing; + if (loaded.endedAt !== null) return loaded; + return loaded; +} + +type TaskNotificationOrigin = Pick; + +function isTaskOrigin(origin: unknown): origin is TaskNotificationOrigin { + if (typeof origin !== 'object' || origin === null) return false; + const value = origin as Record; + return ( + (value['kind'] === 'background_task' || value['kind'] === 'task') && + typeof value['taskId'] === 'string' && + typeof value['status'] === 'string' && + typeof value['notificationId'] === 'string' + ); +} + +function taskNotificationId(taskId: string, status: string): string { + return `task:${taskId}:${status}`; +} + +function notificationKey(origin: TaskNotificationOrigin): string { + return `${origin.taskId}\0${origin.status}\0${origin.notificationId}`; +} + +function taskOriginFromMessage(message: unknown): TaskNotificationOrigin | undefined { + if (typeof message !== 'object' || message === null) return undefined; + const origin = (message as { readonly origin?: unknown }).origin; + return isTaskOrigin(origin) ? origin : undefined; +} + +function buildAgentTaskNotificationBody(info: AgentTaskInfo): string { + const baseLine = + info.status === 'timed_out' + ? `${info.description} timed out.` + : info.status === 'killed' && isSerializedUserCancellation(info.stopReason) + ? `${info.description} was stopped by user.` + : info.stopReason + ? `${info.description} ${info.status === 'killed' ? 'was stopped' : info.status}. Reason: ${info.stopReason}` + : `${info.description} ${info.status}.`; + + if (info.kind !== 'agent') return baseLine; + if (info.status === 'completed') return baseLine; + const agentId = info.agentId; + if (agentId === undefined || agentId === info.taskId) return baseLine; + + const recovery = [ + '', + `To recover or continue this subagent, call Agent(resume="${agentId}", prompt="Pick up where you left off; redo the last tool call if its result was never observed.").`, + `Use agent_id ("${agentId}"), NOT source_id / task_id ("${info.taskId}") — the two look alike but only agent_id is accepted by the resume parameter.`, + 'Add run_in_background=true to keep it backgrounded, or omit it to take the result inline in the current turn.', + 'The subagent retains its full prior context across the restart, but any in-flight tool call lost its result and may need to be redone.', + ].join('\n'); + + return `${baseLine}${recovery}`; +} + +function buildAgentTaskNotification( + info: AgentTaskInfo, + output?: AgentTaskOutputSnapshot, +): AgentTaskNotification { + const question = questionNotificationText(info, output); + return { + id: taskNotificationId(info.taskId, info.status), + category: 'task', + type: `task.${info.status}`, + source_kind: 'background_task', + source_id: info.taskId, + agent_id: info.kind === 'agent' ? info.agentId : undefined, + title: question?.title ?? `Background ${info.kind} ${info.status}`, + severity: info.status === 'completed' ? 'info' : 'warning', + body: question?.body ?? buildAgentTaskNotificationBody(info), + children: agentTaskNotificationChildren(info, output), + }; +} + +function generateTaskId(kind: string): string { + const bytes = randomBytes(8); + let suffix = ''; + for (let index = 0; index < 8; index++) { + suffix += TASK_ID_ALPHABET[bytes[index]! % TASK_ID_ALPHABET.length]; + } + return `${kind}-${suffix}`; +} + +function normalizeReason(reason: string | undefined): string | undefined { + const trimmed = reason?.trim(); + return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; +} + +function isSerializedUserCancellation(reason: string | undefined): boolean { + return reason === userCancellationReason().message; +} + +function createForegroundRelease(): ForegroundRelease { + let resolve!: (reason: ForegroundTaskReleaseReason) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function errorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} + +function previousSessionTaskLine(info: AgentTaskInfo): string { + if (info.kind === 'agent' && info.agentId !== undefined) { + return `- ${info.taskId} "${info.description}" (subagent) — resume it with Agent(resume="${info.agentId}", prompt="Pick up where you left off; redo the last tool call if its result was never observed.") to continue from its prior context.`; + } + return `- ${info.taskId} "${info.description}" (${info.kind === 'process' ? 'bash' : info.kind})`; +} + +function isPreviousSessionTermination(info: AgentTaskInfo): boolean { + if (info.status === 'lost') return true; + return ( + info.status === 'killed' && + info.terminalNotificationSuppressed === true && + info.stopReason === SESSION_CLOSED_REASON + ); +} + +registerScopedService( + LifecycleScope.Agent, + IAgentTaskService, + AgentTaskService, + ScopeActivation.OnScopeCreated, + 'task', +); diff --git a/packages/agent-core-v2/src/agent/task/tools/format.ts b/packages/agent-core-v2/src/agent/task/tools/format.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f14bc12707d56705c77b322cc7d72c900561eeb --- /dev/null +++ b/packages/agent-core-v2/src/agent/task/tools/format.ts @@ -0,0 +1,14 @@ +function formatValue(value: unknown): string { + return typeof value === 'string' ? value : String(value); +} + +function fieldName(key: string): string { + return key.replaceAll(/[A-Z]/g, (match) => `_${match.toLowerCase()}`); +} + +export function formatPlainObject(record: object): string { + return Object.entries(record) + .filter(([, value]) => value !== undefined && value !== null) + .map(([key, value]) => `${fieldName(key)}: ${formatValue(value)}`) + .join('\n'); +} diff --git a/packages/agent-core-v2/src/agent/task/types.ts b/packages/agent-core-v2/src/agent/task/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..3ab3cfb5aa9c3edd3daf6ee3125ee616fabfa7a9 --- /dev/null +++ b/packages/agent-core-v2/src/agent/task/types.ts @@ -0,0 +1,112 @@ +export type AgentTaskStatus = + | 'running' + | 'completed' + | 'failed' + | 'timed_out' + | 'killed' + | 'lost'; + +export const TERMINAL_STATUSES: ReadonlySet = new Set([ + 'completed', + 'failed', + 'timed_out', + 'killed', + 'lost', +]); +export type AgentTaskSettlementStatus = 'completed' | 'failed' | 'timed_out' | 'killed'; + +export interface AgentTaskSettlement { + readonly status: AgentTaskSettlementStatus; + readonly stopReason?: string; +} + +export interface AgentTaskInfoBase { + readonly taskId: string; + readonly description: string; + readonly status: AgentTaskStatus; + readonly detached?: boolean; + readonly startedAt: number; + readonly endedAt: number | null; + readonly stopReason?: string; + readonly terminalNotificationSuppressed?: boolean; + readonly resumeReminded?: boolean; + readonly timeoutMs?: number; +} + +export interface AgentTaskInfoByKind {} + +export type AgentTaskKind = Extract; + +export type AgentTaskInfo = AgentTaskInfoByKind[AgentTaskKind]; + +export interface AgentTaskSink { + readonly signal: AbortSignal; + appendOutput(chunk: string): void; + settle(settlement: AgentTaskSettlement): Promise; +} + +export interface AgentTask { + readonly idPrefix: string; + readonly kind: AgentTaskKind; + readonly description: string; + readonly timeoutMs?: number; + + start(sink: AgentTaskSink): void | Promise; + onDetach?(): void; + forceStop?(): Promise; + toInfo(base: AgentTaskInfoBase): AgentTaskInfo; +} + +export interface TaskInfoBase { + readonly taskId: string; + readonly description: string; + readonly status: AgentTaskStatus; + readonly detached?: boolean; + readonly startedAt: number; + readonly endedAt: number | null; + readonly stopReason?: string; + readonly terminalNotificationSuppressed?: boolean; + readonly timeoutMs?: number; +} + +export type TaskInfo = TaskInfoBase & + ( + | { + readonly kind: 'process'; + readonly command: string; + readonly pid: number; + readonly exitCode: number | null; + } + | { + readonly kind: 'agent'; + readonly agentId?: string; + readonly subagentType?: string; + readonly model?: string; + readonly thinkingEffort?: string; + } + | { + readonly kind: 'question'; + readonly questionCount: number; + readonly toolCallId?: string; + } + ); + +export interface TaskStartedEvent { + readonly type: 'task.started'; + readonly info: TaskInfo; +} + +export interface TaskTerminatedEvent { + readonly type: 'task.terminated'; + readonly info: TaskInfo; +} + +export interface BackgroundTaskStartedEvent { + readonly type: 'background.task.started'; + readonly info: TaskInfo; +} + +export interface BackgroundTaskTerminatedEvent { + readonly type: 'background.task.terminated'; + readonly info: TaskInfo; +} diff --git a/packages/agent-core-v2/src/agent/tokenCounting/configSection.ts b/packages/agent-core-v2/src/agent/tokenCounting/configSection.ts new file mode 100644 index 0000000000000000000000000000000000000000..e1e0d382e66c83c5c76d07a9f85d96ca9b19b595 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tokenCounting/configSection.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; + +import { + type ConfigSchema, + type EnvBindings, + envBindings, + stripEnvBoundFields, +} from '#/app/config/config'; +import { registerConfigSection } from '#/app/config/configSectionContributions'; +import type { TokenCountingStrategy } from './tokenCounting'; + +export const TOKEN_COUNTING_SECTION = 'tokenCounting'; + +export const TOKEN_COUNTING_STRATEGY_ENV = 'KIMI_TOKEN_COUNTING_STRATEGY'; + +export const TOKEN_COUNTING_STRATEGIES = ['measured+estimated', 'measured', 'estimated'] as const; + +export const TokenCountingConfigSchema: ConfigSchema = z.object({ + strategy: z.enum(TOKEN_COUNTING_STRATEGIES), +}); + +export type TokenCountingConfig = { + readonly strategy: TokenCountingStrategy; +}; + +function parseStrategy(raw: string): TokenCountingStrategy | undefined { + const value = raw.trim().toLowerCase(); + return (TOKEN_COUNTING_STRATEGIES as readonly string[]).includes(value) + ? (value as TokenCountingStrategy) + : undefined; +} + +export const tokenCountingEnvBindings: EnvBindings = + envBindings(TokenCountingConfigSchema, { + strategy: { env: TOKEN_COUNTING_STRATEGY_ENV, parse: parseStrategy }, + }); + +export const stripTokenCountingEnv = stripEnvBoundFields( + tokenCountingEnvBindings, +); + +export const DEFAULT_TOKEN_COUNTING_CONFIG: TokenCountingConfig = { + strategy: 'measured+estimated', +}; + +registerConfigSection(TOKEN_COUNTING_SECTION, TokenCountingConfigSchema, { + defaultValue: DEFAULT_TOKEN_COUNTING_CONFIG, + env: tokenCountingEnvBindings, + stripEnv: stripTokenCountingEnv, +}); diff --git a/packages/agent-core-v2/src/agent/tokenCounting/tokenCounting.ts b/packages/agent-core-v2/src/agent/tokenCounting/tokenCounting.ts new file mode 100644 index 0000000000000000000000000000000000000000..6eae3ee0de89adb0dc3416a5e90f8aebe6efcadd --- /dev/null +++ b/packages/agent-core-v2/src/agent/tokenCounting/tokenCounting.ts @@ -0,0 +1,16 @@ +import type { Message } from '#/llm-adapter/contract/message'; +import type { ToolDescription as Tool } from '#human/llm/message'; + +export type TokenCountingStrategy = 'measured+estimated' | 'measured' | 'estimated'; + +export interface ContextSize { + readonly size: number; + readonly measured: number; + readonly estimated: number; +} + +export interface TokenCountingRequest { + readonly systemPrompt: string; + readonly tools: readonly Tool[]; + readonly messages: readonly Message[]; +} diff --git a/packages/agent-core-v2/src/agent/tokenCounting/tokenCountingOps.ts b/packages/agent-core-v2/src/agent/tokenCounting/tokenCountingOps.ts new file mode 100644 index 0000000000000000000000000000000000000000..06dbf7aa7c37a158c18929e204b8bf3e7f0a4c08 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tokenCounting/tokenCountingOps.ts @@ -0,0 +1,80 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { z } from 'zod'; + +import { AgentEvent2 } from '#/app/event/event2'; + +export interface TokenAnchor { + readonly length: number; + readonly tokens: number; + readonly measured: boolean; +} + +export interface TokenCountingState { + readonly anchors: readonly TokenAnchor[]; + readonly tokens: number; +} + +const sizeSchema = z.object({ + agentId: z.string(), + length: z.number(), + tokens: z.number(), +}); + +export class TokenCountingMeasured extends AgentEvent2> { + static override readonly type = 'token_counting.measured'; + static override readonly durable = true; + static override readonly schema = sizeSchema; +} +export interface TokenCountingMeasured { + readonly agentId: string; + readonly length: number; + readonly tokens: number; +} + +export class TokenCountingTruncated extends AgentEvent2> { + static override readonly type = 'token_counting.truncated'; + static override readonly durable = true; + static override readonly schema = sizeSchema; +} +export interface TokenCountingTruncated { + readonly agentId: string; + readonly length: number; + readonly tokens: number; +} + +const rebaseSchema = sizeSchema.extend({ measured: z.boolean() }); + +export class TokenCountingRebased extends AgentEvent2> { + static override readonly type = 'token_counting.rebased'; + static override readonly durable = true; + static override readonly schema = rebaseSchema; +} +export interface TokenCountingRebased { + readonly agentId: string; + readonly length: number; + readonly tokens: number; + readonly measured: boolean; +} + +const turnRecordedSchema = sizeSchema.extend({ turnId: z.number() }); + +export class TokenCountingTurnRecorded extends AgentEvent2> { + static override readonly type = 'token_counting.turn_recorded'; + static override readonly durable = true; + static override readonly schema = turnRecordedSchema; +} +export interface TokenCountingTurnRecorded { + readonly agentId: string; + readonly length: number; + readonly tokens: number; + readonly turnId: number; +} + +export function anchorsEqual(a: readonly TokenAnchor[], b: readonly TokenAnchor[]): boolean { + return a.length === b.length && a.every((anchor, i) => anchor === b[i]); +} + +export function normalizeAnchorLength(length: number): number { + if (!Number.isFinite(length)) return 0; + return Math.max(0, Math.floor(length)); +} diff --git a/packages/agent-core-v2/src/agent/toolActivation/toolActivation.ts b/packages/agent-core-v2/src/agent/toolActivation/toolActivation.ts new file mode 100644 index 0000000000000000000000000000000000000000..f13afca20bcc9cc65cf654838a71b43613da0488 --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolActivation/toolActivation.ts @@ -0,0 +1,10 @@ +import { createDecorator } from '#/_base/di/instantiation'; + +export interface IAgentToolActivationService { + readonly _serviceBrand: undefined; + + activate(): Promise; +} + +export const IAgentToolActivationService = + createDecorator('agentToolActivationService'); diff --git a/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts b/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts new file mode 100644 index 0000000000000000000000000000000000000000..93414c8f9a0f57ff6909fa446a63bbcecc5c2c2a --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts @@ -0,0 +1,111 @@ +import { type CollectionView } from '#/_base/di/collection'; +import { IInstantiationService } from '#/_base/di/instantiation'; +import { type IDisposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IEventBus } from '#/app/event/eventBus'; +import { IAgentProfileService } from '#/agent/profile/profile'; +import { AgentStatusUpdated } from '#/agent/usage/usageEvents'; +import { isToolActive } from '#/agent/toolPolicy/evaluate'; +import { SELECT_TOOLS_TOOL_NAME } from '#/agent/toolSelect/toolSelect'; +import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; +import { AgentToolContribution } from '#/agent/toolRegistry/toolContribution'; +import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; +import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; + +import { IAgentToolActivationService } from './toolActivation'; + +export class AgentToolActivationService extends Service implements IAgentToolActivationService { + declare readonly _serviceBrand: undefined; + + private readonly registrations = new Map(); + + constructor( + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, + @IAgentProfileService private readonly profile: IAgentProfileService, + @ISessionToolPolicyGate private readonly toolPolicyGate: ISessionToolPolicyGate, + @IAgentRuntimeService private readonly runtime: IAgentRuntimeService, + @IEventBus eventBus: IEventBus, + @AgentToolContribution private readonly contributions: CollectionView, + ) { + super(); + this._register( + eventBus.subscribe(AgentStatusUpdated, () => { + void this.activate(); + }), + ); + this._register(this.runtime.onDidChange(() => this.refreshRuntimeRecords())); + this._register( + this.contributions.onDidChange((change) => { + this.activateRecords(change.added); + for (const record of change.removed) { + this.deactivateRecord(record); + } + }), + ); + } + + activate(): Promise { + this.activateRecords(this.contributions.items); + return Promise.resolve(); + } + + private activateRecords(records: readonly AgentToolContribution[]): void { + if (records.length === 0) return; + const data = this.profile.data(); + const policy = { tools: data.activeToolNames, disallowedTools: data.disallowedTools }; + const disclosurePolicy = { disallowedTools: data.disallowedTools }; + const workspaceVeto = { disallowedTools: this.toolPolicyGate.disabledTools }; + this.instantiationService.invokeFunction((accessor) => { + for (const record of records) { + const { id, options } = record; + const source = options.source ?? 'builtin'; + if (this.toolRegistry.resolve(options.name) !== undefined) continue; + if (!this.runtimeAllows(record)) continue; + if (!isToolActive(workspaceVeto, options.name, source)) continue; + const activeByProfile = + options.name === SELECT_TOOLS_TOOL_NAME + ? isToolActive(disclosurePolicy, options.name, source) + : isToolActive(policy, options.name, source); + if (!activeByProfile) continue; + if (options.when !== undefined && !options.when(accessor)) continue; + const tool = accessor.get(id); + const registration = this.toolRegistry.register(tool, { + source: options.source, + disclosure: options.disclosure, + }); + this.registrations.set(record, registration); + this._register(registration); + } + }); + } + + private refreshRuntimeRecords(): void { + for (const record of this.contributions.items) { + if (!this.runtimeAllows(record)) this.deactivateRecord(record); + } + this.activateRecords(this.contributions.items); + } + + private runtimeAllows(record: AgentToolContribution): boolean { + const required = record.options.requiredRuntimeCapabilities; + return required === undefined || this.runtime.isAvailable(required); + } + + private deactivateRecord(record: AgentToolContribution): void { + const registration = this.registrations.get(record); + if (registration === undefined) return; + this.registrations.delete(record); + registration.dispose(); + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentToolActivationService, + AgentToolActivationService, + ScopeActivation.OnScopeCreated, + 'toolActivation', +); diff --git a/packages/agent-core-v2/src/agent/toolApproval/toolApproval.ts b/packages/agent-core-v2/src/agent/toolApproval/toolApproval.ts new file mode 100644 index 0000000000000000000000000000000000000000..7caf10be5d9cb751bad023f6184177497d1e50ad --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolApproval/toolApproval.ts @@ -0,0 +1,37 @@ +import { createDecorator } from '#/_base/di/instantiation'; +import type { + ApprovalResponse, + PermissionPolicyResolution, + PermissionPolicyResult, +} from '#/agent/permissionPolicy/types'; +import type { + BeforeExecuteDecision, + ResolvedToolExecutionHookContext, +} from '#/agent/toolExecutor/toolHooks'; + +export interface IAgentToolApprovalService { + readonly _serviceBrand: undefined; + + resolvePermissionResolution( + result: PermissionPolicyResolution, + context: ResolvedToolExecutionHookContext, + origin: string, + ): Promise; + + requestToolApproval( + context: ResolvedToolExecutionHookContext, + result: Extract, + origin: string, + ): Promise; + + formatDenyMessage(message: string): string; + + formatApprovalRejectionMessage( + toolName: string, + result: Pick, + ): string; +} + +export const IAgentToolApprovalService = createDecorator( + 'agentToolApprovalService', +); diff --git a/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts b/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts new file mode 100644 index 0000000000000000000000000000000000000000..96f36f15ec9fcaf00f4ec373e935004ed14857e2 --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts @@ -0,0 +1,268 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { randomUUID } from 'node:crypto'; + +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { abortable, isUserCancellation } from '#/_base/utils/abort'; +import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; +import type { + ApprovalResponse, + PermissionPolicyResolution, + PermissionPolicyResult, +} from '#/agent/permissionPolicy/types'; +import { IAgentPermissionRulesService } from '#/agent/permissionRules/permissionRules'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { denyToolExecution } from '#/agent/toolExecutor/beforeToolExecuteEvent'; +import type { + BeforeExecuteDecision, + ResolvedToolExecutionHookContext, +} from '#/agent/toolExecutor/toolHooks'; +import { AgentEvent2 } from '#/app/event/event2'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { + INTERACTION_TAG_AGENT_ID, + INTERACTION_TAG_SESSION_ID, + INTERACTION_TAG_TOOL_CALL_ID, + INTERACTION_TAG_TURN_ID, + type InteractionTags, +} from '#/human/interaction/interaction'; +import { interactions } from '#/human/interaction/facade'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { IEventDispatcher } from '#/state/eventDispatcher'; +import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; + +import { IAgentToolApprovalService } from './toolApproval'; + +export interface PermissionApprovalRequestedPayload { + readonly id?: string; + readonly sessionId?: string; + readonly agentId: string; + readonly turnId: number; + readonly toolCallId: string; + readonly toolName: string; + readonly action: string; + readonly display: ToolInputDisplay; + readonly toolInput: unknown; +} + +export class PermissionApprovalRequested extends AgentEvent2 { + static override readonly type = 'permission.approval.requested'; + static override readonly observable = true; +} +export interface PermissionApprovalRequested extends PermissionApprovalRequestedPayload {} + +export interface PermissionApprovalResolvedPayload extends PermissionApprovalRequestedPayload { + readonly decision: 'approved' | 'rejected' | 'cancelled' | 'error'; + readonly scope?: 'session'; + readonly feedback?: string; + readonly selectedLabel?: string; + readonly error?: string; +} + +export class PermissionApprovalResolved extends AgentEvent2 { + static override readonly type = 'permission.approval.resolved'; + static override readonly observable = true; +} +export interface PermissionApprovalResolved extends PermissionApprovalResolvedPayload {} + +export class AgentToolApprovalService extends Service implements IAgentToolApprovalService { + declare readonly _serviceBrand: undefined; + + constructor( + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @IAgentPermissionModeService private readonly modeService: IAgentPermissionModeService, + @IAgentPermissionRulesService private readonly rulesService: IAgentPermissionRulesService, + @ISessionContext private readonly session: ISessionContext, + @ITelemetryService private readonly telemetry: ITelemetryService, + @IEventDispatcher private readonly dispatcher: IEventDispatcher, + ) { + super(); + } + + async resolvePermissionResolution( + result: PermissionPolicyResolution, + context: ResolvedToolExecutionHookContext, + origin: string, + ): Promise { + switch (result.kind) { + case 'approve': + return result.executionMetadata === undefined + ? undefined + : { executionMetadata: result.executionMetadata }; + case 'deny': + return { + veto: denyToolExecution( + this.formatDenyMessage( + result.message ?? `Tool "${context.toolCall.name}" was denied by permission policy.`, + ), + ), + }; + case 'ask': + return this.requestToolApproval(context, result, origin); + case 'result': + return { veto: result.result }; + } + } + + async requestToolApproval( + context: ResolvedToolExecutionHookContext, + result: Extract, + origin: string, + ): Promise { + const name = context.toolCall.name; + const action = context.execution.description ?? `Approve ${name}`; + const display = + context.execution.display ?? + ({ + kind: 'generic', + summary: action, + detail: context.args, + } as ToolInputDisplay); + const approvalRequest = { + id: `approval_${randomUUID()}`, + sessionId: this.session.sessionId, + agentId: this.scopeContext.agentId, + turnId: context.turnId, + toolCallId: context.toolCall.id, + toolName: name, + action, + display, + }; + const approvalContext = { + ...approvalRequest, + toolInput: context.args, + } satisfies PermissionApprovalRequestedPayload; + const startedAt = Date.now(); + + const tags: InteractionTags = { + [INTERACTION_TAG_AGENT_ID]: this.scopeContext.agentId, + [INTERACTION_TAG_SESSION_ID]: this.session.sessionId, + [INTERACTION_TAG_TURN_ID]: context.turnId, + [INTERACTION_TAG_TOOL_CALL_ID]: context.toolCall.id, + }; + let response: ApprovalResponse; + void this.dispatcher.dispatch(new PermissionApprovalRequested(approvalContext)); + try { + response = await abortable( + interactions.request({ + id: approvalRequest.id, + kind: 'approval', + payload: approvalRequest, + tags, + }), + context.signal, + ); + context.signal.throwIfAborted(); + } catch (error) { + if (isUserCancellation(error)) throw error; + this.telemetry.track2('permission_approval_result', { + turn_id: context.turnId, + tool_call_id: context.toolCall.id, + policy_name: origin, + tool_name: name, + permission_mode: this.modeService.mode, + result: 'error', + approval_surface: display.kind, + duration_ms: Date.now() - startedAt, + session_cache_written: false, + has_feedback: false, + trace_id: context.trace?.traceId, + }); + void this.dispatcher.dispatch( + new PermissionApprovalResolved({ + ...approvalContext, + decision: 'error', + error: error instanceof Error ? error.message : String(error), + }), + ); + const resolved = result.resolveError?.(error); + if (resolved !== undefined) { + return this.resolvePermissionResolution(resolved, context, origin); + } + throw error; + } + + const sessionApprovalRule = + response.decision === 'approved' && response.scope === 'session' + ? context.execution.approvalRule + : undefined; + void this.dispatcher.dispatch( + new PermissionApprovalResolved({ + ...approvalContext, + ...response, + }), + ); + this.rulesService.recordApprovalResult({ + turnId: context.turnId, + toolCallId: context.toolCall.id, + toolName: name, + action, + sessionApprovalRule, + result: response, + }); + this.telemetry.track2('permission_approval_result', { + turn_id: context.turnId, + tool_call_id: context.toolCall.id, + policy_name: origin, + tool_name: name, + permission_mode: this.modeService.mode, + result: + response.decision === 'approved' && response.scope === 'session' + ? 'approved_for_session' + : response.decision, + approval_surface: display.kind, + duration_ms: Date.now() - startedAt, + session_cache_written: sessionApprovalRule !== undefined, + has_feedback: response.feedback !== undefined && response.feedback.length > 0, + trace_id: context.trace?.traceId, + }); + + const resolved = result.resolveApproval?.(response); + if (resolved !== undefined) { + return this.resolvePermissionResolution(resolved, context, origin); + } + + if (response.decision === 'approved') return undefined; + return { + veto: denyToolExecution(this.formatApprovalRejectionMessage(name, response)), + }; + } + + formatApprovalRejectionMessage( + toolName: string, + result: Pick, + ): string { + const suffix = + result.feedback !== undefined && result.feedback.length > 0 + ? ` Reason: ${result.feedback}` + : ''; + const prefix = + result.decision === 'cancelled' + ? `Tool "${toolName}" was not run because the approval request was cancelled.` + : `Tool "${toolName}" was not run because the user rejected the approval request.`; + if (this.usesWorkerRejectionGuidance()) { + return `${prefix}${suffix} Try a different approach — don't retry the same call, don't attempt to bypass the restriction.`; + } + return `${prefix}${suffix}`; + } + + formatDenyMessage(message: string): string { + if (this.usesWorkerRejectionGuidance()) { + return `${message} Try a different approach — don't retry the same call, don't attempt to bypass the restriction.`; + } + return message; + } + + private usesWorkerRejectionGuidance(): boolean { + return this.scopeContext.agentId !== 'main'; + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentToolApprovalService, + AgentToolApprovalService, + ScopeActivation.OnScopeCreated, + 'toolApproval', +); diff --git a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupe.ts b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupe.ts new file mode 100644 index 0000000000000000000000000000000000000000..82783bad1ad8524b92a10f3fce0732989a0b9f11 --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupe.ts @@ -0,0 +1,25 @@ +import type { ContentPart } from '#human/llm/message'; + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ExecutableToolErrorResult, ExecutableToolSuccessResult } from '#/tool/toolContract'; + +export type ToolDedupeOutput = string | ContentPart[]; + +export interface ToolDedupeSuccessResult extends ExecutableToolSuccessResult { + readonly message?: string | undefined; +} + +export interface ToolDedupeErrorResult extends ExecutableToolErrorResult { + readonly message?: string | undefined; +} + +export type ToolDedupeResult = ToolDedupeSuccessResult | ToolDedupeErrorResult; + +export const REPEAT_BREAKER_STOP_REASON = 'repeat_breaker'; + +export interface IAgentToolDedupeService { + readonly _serviceBrand: undefined; +} + +export const IAgentToolDedupeService: ServiceIdentifier = + createDecorator('agentToolDedupeService'); diff --git a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts new file mode 100644 index 0000000000000000000000000000000000000000..4acf2a3b24a0df0c2556af4319911723aa501118 --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts @@ -0,0 +1,579 @@ +import { createHash } from 'node:crypto'; + +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { defineState } from '#/state/state'; +import { canonicalTelemetryArgs } from '#/_base/utils/canonical-args'; +import type { + ToolCallDedupDetectedEvent, + ToolCallRepeatEvent, + ToolCallRepeatHandoffEvent, + ToolCallTurnRepeatEvent, +} from '#/app/telemetry/events'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; +import { parseToolCallArguments } from '#/tool/tool-args-parse'; +import { IAgentLoopService } from '#/agent/loop/loop'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { IEventBus } from '#/app/event/eventBus'; +import { TurnEnded } from '#/agent/loop/turnOps'; +import { wrapSystemReminder } from '#/features/reminder/systemReminder'; +import { IAgentToolExecutorService, type ToolCallDupType } from '#/agent/toolExecutor/toolExecutor'; +import type { ContentPart } from '#human/llm/message'; +import { + IAgentToolDedupeService, + REPEAT_BREAKER_STOP_REASON, + type ToolDedupeResult, +} from './toolDedupe'; + +const REMINDER_TEXT_1 = + '\n\n' + + wrapSystemReminder( + 'The same tool call has been repeated several times in a row. ' + + 'Before making your next call, write one sentence stating what new information you expect it to produce. ' + + 'Then act on that sentence: if it names something this result does not already give you, choose the action that best provides it; otherwise, continue with the evidence you already have.', + ); + +function makeReminderText2(repeatCount: number): string { + return ( + '\n\n' + + wrapSystemReminder( + `The same tool call has now been issued ${String(repeatCount)} times in a row. ` + + 'Choose exactly one of the following and state your choice before acting:\n' + + '(1) Falsification check: run the cheapest test that could conclusively disprove your current approach, if such a test exists.\n' + + '(2) Missing input: tell the user precisely what information or decision you need to proceed, and ask for it.\n' + + '(3) Conclude: deliver your best result based on the evidence already gathered, listing anything that remains uncertain.', + ) + ); +} + +const REMINDER_TEXT_3 = + '\n\n' + + wrapSystemReminder( + 'Write your final response now, without any further tool calls. ' + + 'Cover: the current blocker, each approach you have tried and what it established, and the specific information or decision you need from the user to unblock progress. ' + + 'Text only.', + ); + +const REPEAT_REMINDER_1_START = 3; +const REPEAT_REMINDER_2_START = 5; +const REPEAT_REMINDER_3_START = 8; +const REPEAT_FORCE_STOP_STREAK = 12; + +const HANDOFF_VETO_TEXT = + 'This turn was ended by the repeat breaker after the same tool call was issued ' + + `${String(REPEAT_FORCE_STOP_STREAK)} times in a row. This step accepts a text response only, ` + + 'so the tool call was not executed. Reply in text: the current blocker, what you tried, ' + + 'and what you need next.'; + +const HANDOFF_VETO_RESULT: ToolDedupeResult = { + output: HANDOFF_VETO_TEXT, + isError: true, + stopTurn: true, + stopTurnReason: REPEAT_BREAKER_STOP_REASON, +}; + +type HandoffPhase = 'idle' | 'pending' | 'active' | 'done'; + +interface Deferred { + readonly promise: Promise; + resolve(value: T): void; +} + +function makeDeferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +function makeKey(toolName: string, args: unknown): string { + return `${toolName} ${canonicalTelemetryArgs(args)}`; +} + +function argsHash(args: unknown): string { + return createHash('sha256').update(canonicalTelemetryArgs(args)).digest('hex').slice(0, 8); +} + +function callSignature(key: string): string { + return createHash('sha256').update(key).digest('hex'); +} + +interface CheckedToolCall { + readonly syntheticResult: ToolDedupeResult | null; +} + +interface TurnCallRecord { + count: number; + lastStep: number; +} + +function appendReminder(result: ToolDedupeResult, reminderText: string): ToolDedupeResult { + const output = result.output; + let newOutput: string | ContentPart[]; + if (typeof output === 'string') { + newOutput = output + reminderText; + } else { + const arr: ContentPart[] = [...output]; + const last = arr.at(-1); + if (last !== undefined && last.type === 'text') { + arr[arr.length - 1] = { type: 'text', text: last.text + reminderText }; + } else { + arr.push({ type: 'text', text: reminderText }); + } + newOutput = arr; + } + const spill = + result.spill !== undefined + ? { ...result.spill, suffix: (result.spill.suffix ?? '') + reminderText } + : undefined; + return result.isError === true + ? { ...result, output: newOutput, isError: true, spill } + : { ...result, output: newOutput, spill }; +} + +function forceStopResult(result: ToolDedupeResult, reminderText: string): ToolDedupeResult { + const withReminder = appendReminder(result, reminderText); + return { ...withReminder, stopTurn: true, stopTurnReason: REPEAT_BREAKER_STOP_REASON }; +} + +const DEDUPE_PLACEHOLDER_RESULT: ToolDedupeResult = { output: '' }; + +export const toolDedupeStepCallsKey = defineState('toolDedupe.stepCalls', () => []); +export const toolDedupeOriginalCallIndexKey = defineState>( + 'toolDedupe.originalCallIndex', + () => new Map(), +); +export const toolDedupeSyntheticCallIdsKey = defineState>( + 'toolDedupe.syntheticCallIds', + () => new Set(), +); +export const toolDedupeCallKeyByCallIdKey = defineState>( + 'toolDedupe.callKeyByCallId', + () => new Map(), +); +export const toolDedupeConsecutiveKeyKey = defineState( + 'toolDedupe.consecutiveKey', + () => null, +); +export const toolDedupeConsecutiveCountKey = defineState( + 'toolDedupe.consecutiveCount', + () => 0, +); +export const toolDedupeActiveTurnIdKey = defineState( + 'toolDedupe.activeTurnId', + () => undefined as number | undefined, +); +export const toolDedupeActiveStepKey = defineState('toolDedupe.activeStep', () => 0); +export const toolDedupeTurnCallRecordsKey = defineState>( + 'toolDedupe.turnCallRecords', + () => new Map(), +); +export const toolDedupeTurnRepeatCountKey = defineState( + 'toolDedupe.turnRepeatCount', + () => 0, +); +export const toolDedupeHandoffPhaseKey = defineState( + 'toolDedupe.handoffPhase', + () => 'idle' as HandoffPhase, +); + +export class AgentToolDedupeService extends Service implements IAgentToolDedupeService { + declare readonly _serviceBrand: undefined; + private readonly stepDeferreds = new Map>(); + private readonly handoffVetoedCallIds = new Set(); + private forceStoppedInStep = false; + + constructor( + @ITelemetryService private readonly telemetry: ITelemetryService, + @IAgentLoopService private readonly loop: IAgentLoopService, + @IAgentToolExecutorService private readonly toolExecutor: IAgentToolExecutorService, + @IAgentStateService private readonly states: IAgentStateService, + @IEventBus eventBus: IEventBus, + ) { + super(); + this.states.contributeState(toolDedupeStepCallsKey); + this.states.contributeState(toolDedupeOriginalCallIndexKey); + this.states.contributeState(toolDedupeSyntheticCallIdsKey); + this.states.contributeState(toolDedupeCallKeyByCallIdKey); + this.states.contributeState(toolDedupeConsecutiveKeyKey); + this.states.contributeState(toolDedupeConsecutiveCountKey); + this.states.contributeState(toolDedupeActiveTurnIdKey); + this.states.contributeState(toolDedupeActiveStepKey); + this.states.contributeState(toolDedupeTurnCallRecordsKey); + this.states.contributeState(toolDedupeTurnRepeatCountKey); + this.states.contributeState(toolDedupeHandoffPhaseKey); + this._register(eventBus.subscribe(TurnEnded, () => this.clearTurnRecords())); + loop.hooks.onWillBeginStep.register('toolDedupe', async (ctx, next) => { + this.beginStep(ctx.turnId, ctx.step); + await next(); + }); + loop.hooks.onDidFinishStep.register('toolDedupe', async (ctx, next) => { + this.endStep(); + this.settleHandoff(ctx.turnId); + await next(); + }); + toolExecutor.onBeforeExecuteTool((event) => { + if (this.handoffPhase === 'active') { + this.handoffVetoedCallIds.add(event.toolCall.id); + event.veto(HANDOFF_VETO_RESULT); + return; + } + const checked = this.checkToolCall( + event.toolCall.id, + event.toolCall.name, + event.args, + event.trace, + ); + if (checked.syntheticResult !== null) { + event.veto(checked.syntheticResult); + } + }); + toolExecutor.hooks.onDidExecuteTool.register('toolDedupe', async (ctx, next) => { + if (this.handoffPhase === 'active') { + this.handoffVetoedCallIds.add(ctx.toolCall.id); + ctx.result = HANDOFF_VETO_RESULT; + ctx.stopTurn = true; + await next(); + return; + } + this.registerSkipped( + ctx.toolCall.id, + ctx.toolCall.name, + ctx.args, + ctx.toolCall.arguments, + ctx.trace, + ); + ctx.result = await this.finalizeResult( + ctx.toolCall.id, + ctx.toolCall.name, + ctx.args, + ctx.result, + ctx.trace, + ); + if (ctx.result.stopTurn === true) { + ctx.stopTurn = true; + } + await next(); + }); + } + + private get stepCalls(): string[] { + return this.states.get(toolDedupeStepCallsKey); + } + + private set stepCalls(value: string[]) { + this.states.set(toolDedupeStepCallsKey, value); + } + + private get originalCallIndex(): Map { + return this.states.get(toolDedupeOriginalCallIndexKey); + } + + private get syntheticCallIds(): Set { + return this.states.get(toolDedupeSyntheticCallIdsKey); + } + + private get callKeyByCallId(): Map { + 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 get turnCallRecords(): Map { + return this.states.get(toolDedupeTurnCallRecordsKey); + } + + private get turnRepeatCount(): number { + return this.states.get(toolDedupeTurnRepeatCountKey); + } + + private set turnRepeatCount(value: number) { + this.states.set(toolDedupeTurnRepeatCountKey, value); + } + + private get handoffPhase(): HandoffPhase { + return this.states.get(toolDedupeHandoffPhaseKey); + } + + private set handoffPhase(value: HandoffPhase) { + this.states.set(toolDedupeHandoffPhaseKey, value); + } + + private clearTurnRecords(): void { + this.turnCallRecords.clear(); + this.turnRepeatCount = 0; + } + + private beginStep(turnId?: number, step?: number): void { + if (turnId !== undefined && turnId !== this.activeTurnId) { + this.activeTurnId = turnId; + this.consecutiveKey = null; + this.consecutiveCount = 0; + this.handoffPhase = 'idle'; + this.clearTurnRecords(); + } + if (step !== undefined) { + this.activeStep = step; + } + this.forceStoppedInStep = false; + this.handoffVetoedCallIds.clear(); + + for (const deferred of this.stepDeferreds.values()) { + deferred.resolve({ + output: 'Tool call deduplicated but original result was lost', + isError: true, + }); + } + this.stepDeferreds.clear(); + this.stepCalls = []; + this.originalCallIndex.clear(); + this.syntheticCallIds.clear(); + this.callKeyByCallId.clear(); + } + + private endStep(): void { + for (const key of this.stepCalls) { + if (key === this.consecutiveKey) { + this.consecutiveCount += 1; + } else { + this.consecutiveKey = key; + this.consecutiveCount = 1; + } + } + } + + private settleHandoff(turnId: number): void { + const phase = this.handoffPhase; + if (phase === 'active') { + this.handoffPhase = 'done'; + const properties: ToolCallRepeatHandoffEvent = { + turn_id: turnId, + outcome: this.handoffVetoedCallIds.size > 0 ? 'vetoed' : 'text', + }; + this.telemetry.track2('tool_call_repeat_handoff', properties); + return; + } + if (phase !== 'idle' || !this.forceStoppedInStep) return; + this.handoffPhase = 'pending'; + this.loop.notify({ + bypassMaxSteps: true, + onConsume: () => { + this.handoffPhase = 'active'; + }, + onDrop: () => { + this.handoffPhase = 'done'; + }, + }); + } + + private recordTurnRepeat( + toolCallId: string, + toolName: string, + args: unknown, + key: string, + trace: LLMRequestTrace | undefined, + ): void { + const signature = callSignature(key); + const record = this.turnCallRecords.get(signature); + if (record === undefined) { + this.turnCallRecords.set(signature, { count: 0, lastStep: this.activeStep }); + return; + } + if (record.lastStep === this.activeStep) return; + + record.count += 1; + record.lastStep = this.activeStep; + this.turnRepeatCount += 1; + const properties: ToolCallTurnRepeatEvent = { + turn_id: this.activeTurnId, + step_no: this.activeStep, + tool_call_id: toolCallId, + tool_name: toolName, + turn_repeat_count: this.turnRepeatCount, + args_hash: argsHash(args), + trace_id: trace?.traceId, + }; + this.telemetry.track2('tool_call_turn_repeat', properties); + } + + private checkToolCall( + toolCallId: string, + toolName: string, + args: unknown, + trace: LLMRequestTrace | undefined, + ): CheckedToolCall { + const key = makeKey(toolName, args); + const index = this.stepCalls.length; + this.stepCalls.push(key); + this.callKeyByCallId.set(toolCallId, key); + + const existing = this.stepDeferreds.get(key); + if (existing !== undefined) { + this.syntheticCallIds.add(toolCallId); + this.recordDupType(toolCallId, toolName, args, 'same_step', trace); + return { syntheticResult: DEDUPE_PLACEHOLDER_RESULT }; + } + this.recordTurnRepeat(toolCallId, toolName, args, key, trace); + this.stepDeferreds.set(key, makeDeferred()); + this.originalCallIndex.set(toolCallId, index); + if (this.consecutiveKey === key && this.consecutiveCount > 0) { + this.recordDupType(toolCallId, toolName, args, 'cross_step', trace); + return { syntheticResult: null }; + } + return { syntheticResult: null }; + } + + private registerSkipped( + toolCallId: string, + toolName: string, + args: unknown, + rawArguments: unknown, + trace: LLMRequestTrace | undefined, + ): void { + if (this.callKeyByCallId.has(toolCallId)) return; + const keyArgs = + rawArguments !== undefined && + rawArguments !== null && + parseToolCallArguments(rawArguments).parseFailed + ? rawArguments + : args; + this.checkToolCall(toolCallId, toolName, keyArgs, trace); + } + + private recordDupType( + toolCallId: string, + toolName: string, + args: unknown, + dupType: ToolCallDupType, + trace: LLMRequestTrace | undefined, + ): void { + this.toolExecutor.recordDupType(toolCallId, dupType); + const properties: ToolCallDedupDetectedEvent = { + turn_id: this.activeTurnId, + step_no: this.activeStep, + tool_call_id: toolCallId, + tool_name: toolName, + dup_type: dupType, + args_hash: argsHash(args), + trace_id: trace?.traceId, + }; + this.telemetry.track2('tool_call_dedup_detected', properties); + } + + private async finalizeResult( + toolCallId: string, + toolName: string, + args: unknown, + result: ToolDedupeResult, + trace: LLMRequestTrace | undefined, + ): Promise { + const key = this.callKeyByCallId.get(toolCallId); + if (key === undefined) return result; + this.callKeyByCallId.delete(toolCallId); + + if (this.syntheticCallIds.delete(toolCallId)) { + const deferred = this.stepDeferreds.get(key); + if (deferred === undefined) return result; + return deferred.promise; + } + const index = this.originalCallIndex.get(toolCallId); + if (index === undefined) return result; + this.originalCallIndex.delete(toolCallId); + + let lastKey = this.consecutiveKey; + let streak = this.consecutiveCount; + for (let i = 0; i <= index; i += 1) { + const k = this.stepCalls[i]!; + if (k === lastKey) { + streak += 1; + } else { + lastKey = k; + streak = 1; + } + } + + let finalResult = result; + let action: 'none' | 'r1' | 'r2' | 'r3' | 'stop' = 'none'; + if (streak >= REPEAT_FORCE_STOP_STREAK) { + finalResult = forceStopResult(result, REMINDER_TEXT_3); + action = 'stop'; + this.forceStoppedInStep = true; + } else if (streak >= REPEAT_REMINDER_3_START) { + finalResult = appendReminder(result, REMINDER_TEXT_3); + action = 'r3'; + } else if (streak >= REPEAT_REMINDER_2_START) { + finalResult = appendReminder(result, makeReminderText2(streak)); + action = 'r2'; + } else if (streak >= REPEAT_REMINDER_1_START) { + finalResult = appendReminder(result, REMINDER_TEXT_1); + action = 'r1'; + } + + if (streak >= 2) { + const properties: ToolCallRepeatEvent = { + turn_id: this.activeTurnId, + tool_name: toolName, + repeat_count: streak, + action, + trace_id: trace?.traceId, + }; + this.telemetry.track2('tool_call_repeat', properties); + } + + this.stepDeferreds.get(key)?.resolve(finalResult); + return finalResult; + } +} + +export const __testing = { + REMINDER_TEXT_1, + REMINDER_TEXT_3, + makeReminderText2, + REPEAT_REMINDER_1_START, + REPEAT_REMINDER_2_START, + REPEAT_REMINDER_3_START, + REPEAT_FORCE_STOP_STREAK, + REPEAT_BREAKER_STOP_REASON, + HANDOFF_VETO_TEXT, +}; + +registerScopedService( + LifecycleScope.Agent, + IAgentToolDedupeService, + AgentToolDedupeService, + ScopeActivation.OnScopeCreated, + 'toolDedupe', +); diff --git a/packages/agent-core-v2/src/agent/toolExecutor/beforeToolExecuteEvent.ts b/packages/agent-core-v2/src/agent/toolExecutor/beforeToolExecuteEvent.ts new file mode 100644 index 0000000000000000000000000000000000000000..0ccc3e0237322ac9ff8394c5b66cfe338dbfcc86 --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolExecutor/beforeToolExecuteEvent.ts @@ -0,0 +1,121 @@ +import { Emitter } from '#/_base/event'; +import { BugIndicatingError } from '#/errors'; +import type { ToolCall } from '#human/llm/message'; +import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; +import type { + ExecutableTool, + ExecutableToolResult, + RunnableToolExecution, +} from '#/tool/toolContract'; + +import type { + BeforeExecuteDecision, + BeforeToolExecuteEvent, + ResolvedToolExecutionHookContext, +} from './toolHooks'; + +type PendingVetoFactory = () => Promise; + +export function denyToolExecution(reason: string): ExecutableToolResult { + return { output: reason, isError: true }; +} + +export class BeforeToolExecuteEventImpl implements BeforeToolExecuteEvent { + readonly turnId: number; + readonly signal: AbortSignal; + readonly trace?: LLMRequestTrace; + readonly toolCall: ToolCall; + readonly toolCalls: readonly ToolCall[]; + readonly tool?: ExecutableTool | undefined; + readonly args: unknown; + readonly execution: RunnableToolExecution; + + private _vetoResult: ExecutableToolResult | undefined; + private _finalAllowed = false; + private _passMetadata: unknown; + private readonly _pendingVetos: PendingVetoFactory[] = []; + private _open = true; + + constructor(context: ResolvedToolExecutionHookContext) { + this.turnId = context.turnId; + this.signal = context.signal; + this.trace = context.trace; + this.toolCall = context.toolCall; + this.toolCalls = context.toolCalls; + this.tool = context.tool; + this.args = context.args; + this.execution = context.execution; + } + + veto(result: ExecutableToolResult): void { + this.assertOpen('veto'); + this._vetoResult ??= result; + } + + allow(): void { + this.assertOpen('allow'); + this._finalAllowed = true; + } + + pass(metadata?: unknown): void { + this.assertOpen('pass'); + this._passMetadata ??= metadata; + } + + waitUntil(factory: PendingVetoFactory): void { + this.assertOpen('waitUntil'); + this._pendingVetos.push(factory); + } + + get vetoResult(): ExecutableToolResult | undefined { + return this._vetoResult; + } + + get finalAllowed(): boolean { + return this._finalAllowed; + } + + get passMetadata(): unknown { + return this._passMetadata; + } + + get pendingVetos(): readonly PendingVetoFactory[] { + return this._pendingVetos; + } + + closeRegistration(): void { + this._open = false; + } + + private assertOpen(statement: string): void { + if (!this._open) { + throw new BugIndicatingError(`${statement} can NOT be called asynchronously`); + } + } +} + +export class BeforeToolExecuteEmitter extends Emitter { + async fireBeforeExecute( + context: ResolvedToolExecutionHookContext, + ): Promise { + if (this.isDisposed || this._listeners === undefined || this._listeners.size === 0) { + return undefined; + } + + const event = new BeforeToolExecuteEventImpl(context); + for (const entry of Array.from(this._listeners)) { + await entry.listener.call(entry.thisArg, event); + if (event.finalAllowed) return undefined; + if (event.vetoResult !== undefined) return { veto: event.vetoResult }; + } + event.closeRegistration(); + + let passMetadata = event.passMetadata; + for (const factory of event.pendingVetos) { + const decision = await factory(); + if (decision?.veto !== undefined) return { veto: decision.veto }; + passMetadata ??= decision?.executionMetadata; + } + return passMetadata === undefined ? undefined : { executionMetadata: passMetadata }; + } +} diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts new file mode 100644 index 0000000000000000000000000000000000000000..c1cf3349d0e50c9593a6ee1f34577a24891b5219 --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts @@ -0,0 +1,67 @@ +import { createDecorator } from '#/_base/di/instantiation'; +import type { IDisposable } from '#/_base/di/lifecycle'; +import type { Event } from '#/_base/event'; +import type { ToolResult } from '#/tool/toolContract'; +import type { + BeforeToolExecuteEvent, + ToolDidExecuteContext, + WillExecuteToolEvent, +} from '#/agent/toolExecutor/toolHooks'; +import type { ToolCall } from '#human/llm/message'; +import type { OrderedHookSlot } from '#/hooks'; +import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; +import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; +import type { ToolSource } from '#/tool/toolContract'; + +export interface ToolCallStartedPayload { + readonly toolCallId: string; + readonly name: string; + readonly args: unknown; + readonly display?: ToolInputDisplay; +} + +export interface ToolExecutorExecuteOptions { + readonly signal: AbortSignal; + readonly steerSignal?: AbortSignal; + readonly turnId: number; + readonly trace?: LLMRequestTrace; + readonly onToolCall?: (payload: ToolCallStartedPayload) => void; +} + +export interface ToolExecutionResult { + readonly toolCallId: string; + readonly toolName: string; + readonly result: ToolResult; +} + +export type MissingToolDescriber = (toolName: string) => string | undefined; +export type UnavailableToolDescriber = (toolName: string) => string | undefined; +export type ToolCallGuard = (tool: { + readonly name: string; + readonly source: ToolSource; +}) => string | undefined; + +export type ToolCallDupType = 'same_step' | 'cross_step'; + +export interface IAgentToolExecutorService { + readonly _serviceBrand: undefined; + + execute(calls: ToolCall[], options: ToolExecutorExecuteOptions): AsyncIterable; + + readonly onBeforeExecuteTool: Event; + + readonly onWillExecuteTool: Event; + + readonly hooks: { + readonly onDidExecuteTool: OrderedHookSlot; + }; + + recordDupType(toolCallId: string, dupType: ToolCallDupType): void; + + registerToolCallGuard(guard: ToolCallGuard): IDisposable; + registerUnavailableToolDescriber(describer: UnavailableToolDescriber): IDisposable; + registerMissingToolDescriber(describer: MissingToolDescriber): IDisposable; +} + +export const IAgentToolExecutorService = + createDecorator('agentToolExecutorService'); diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorEvents.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorEvents.ts new file mode 100644 index 0000000000000000000000000000000000000000..d02e82ece248e41690378d34c57c1c16efc9554c --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorEvents.ts @@ -0,0 +1,90 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import type { ToolCallDeltaPayload } from '#/agent/loop/turnEvents'; +import type { + McpServerStatusEventPayload, + ToolListUpdatedPayload, +} from '#/agent/mcp/mcpEvents'; +import type { + ShellCompletedPayload, + ShellOutputPayload, + ShellStartedPayload, +} from '#/agent/shellCommand/shellCommandService'; +import { AgentEvent2 } from '#/app/event/event2'; +import type { ToolUpdate } from '#/tool/toolContract'; +import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; + +export interface ToolCallStartedPayload { + readonly agentId: string; + readonly turnId: number; + readonly toolCallId: string; + readonly name: string; + readonly args: unknown; + readonly description?: string; + readonly display?: ToolInputDisplay; +} + +export class ToolCallStarted extends AgentEvent2 { + static override readonly type = 'tool.call.started'; + static override readonly observable = true; +} +export interface ToolCallStarted extends ToolCallStartedPayload {} + +export interface ToolProgressPayload { + readonly agentId: string; + readonly turnId: number; + readonly toolCallId: string; + readonly update: ToolUpdate; +} + +export class ToolProgress extends AgentEvent2 { + static override readonly type = 'tool.progress'; + static override readonly observable = true; +} +export interface ToolProgress extends ToolProgressPayload {} + +export interface ToolResultEventPayload { + readonly agentId: string; + readonly turnId: number; + readonly toolCallId: string; + readonly output: unknown; + readonly isError?: boolean; + readonly synthetic?: boolean; +} + +export class ToolResultEvent extends AgentEvent2 { + static override readonly type = 'tool.result'; + static override readonly observable = true; +} +export interface ToolResultEvent extends ToolResultEventPayload {} + +export interface ToolCallDeltaEvent extends Omit { + readonly type: 'tool.call.delta'; +} + +export interface ToolCallStartedEvent extends Omit { + readonly type: 'tool.call.started'; +} + +export interface ToolProgressEvent extends Omit { + readonly type: 'tool.progress'; +} + +export interface ShellOutputEvent extends Omit { + readonly type: 'shell.output'; +} + +export interface ShellStartedEvent extends Omit { + readonly type: 'shell.started'; +} + +export interface ShellCompletedEvent extends Omit { + readonly type: 'shell.completed'; +} + +export interface ToolListUpdatedEvent extends Omit { + readonly type: 'tool.list.updated'; +} + +export interface McpServerStatusEvent extends Omit { + readonly type: 'mcp.server.status'; +} diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts new file mode 100644 index 0000000000000000000000000000000000000000..0432fe51ae6c6a9386d63f6b977531e821cfcf1c --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts @@ -0,0 +1,993 @@ +import { toDisposable } from '#/_base/di/lifecycle'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { AsyncEmitter, type Event } from '#/_base/event'; +import { defineState } from '#/state/state'; +import type { ContentPart, ToolCall } from '#human/llm/message'; +import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; + +import { + compileToolArgsValidator, + validateToolArgs, + type JsonType, + type ToolArgsValidator, +} from '#/tool/args-validator'; +import { parseToolCallArguments } from '#/tool/tool-args-parse'; +import { PathSecurityError } from '#/tool/path-access'; +import { isAbortError, isUserCancellation } from '#/_base/utils/abort'; +import { IEventDispatcher } from '#/state/eventDispatcher'; +import { + ToolAccesses, + type ExecutableTool, + type ExecutableToolResult, + type RunnableToolExecution, + type ToolExecution, + type ToolResult, + type ToolResultSpill, + type ToolUpdate, +} from '#/tool/toolContract'; +import type { + BeforeToolExecuteEvent, + ResolvedToolExecutionHookContext, + ToolDidExecuteContext, + ToolExecutionOutcome, + WillExecuteToolEvent, +} from '#/agent/toolExecutor/toolHooks'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; +import { ILogService } from '#/_base/log/log'; +import type { ToolCallEvent } from '#/app/telemetry/events'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { OrderedHookSlot } from '#/hooks'; +import { IAgentToolResultTruncationService } from '#/agent/toolResultTruncation/toolResultTruncation'; +import { BeforeToolExecuteEmitter } from './beforeToolExecuteEvent'; +import { + IAgentToolExecutorService, + type MissingToolDescriber, + type ToolCallGuard, + type ToolCallDupType, + type ToolExecutionResult, + type ToolExecutorExecuteOptions, + type UnavailableToolDescriber, +} from './toolExecutor'; +import { ToolCallStarted, ToolProgress, ToolResultEvent } from './toolExecutorEvents'; +import { ToolScheduler } from './toolScheduler'; + +const ABORT_GRACE_MS = 2_000; +const TOOL_OUTPUT_EMPTY = 'Tool output is empty.'; +const TOOL_OUTPUT_NON_TEXT = 'Tool returned non-text content.'; + +const validators = new WeakMap< + ExecutableTool, + { schema: Record; validator: ToolArgsValidator } +>(); + +export interface ToolExecutionTask { + readonly accesses: ToolAccesses; + readonly execute: (signal: AbortSignal) => Promise; +} + +export interface ToolExecutionRunResult { + readonly result: ToolResult; + readonly outcome: ToolExecutionOutcome; +} + +interface TimedToolResult { + readonly index: number; + readonly result: ToolResult; + readonly outcome: ToolExecutionOutcome; + readonly durationMs: number; +} + +type SettledTimedToolResult = + | { readonly status: 'fulfilled'; readonly value: TimedToolResult } + | { readonly status: 'rejected'; readonly index: number; readonly reason: unknown }; + +type SettledToolExecutionResult = + | { readonly status: 'fulfilled'; readonly value: ToolExecutionResult } + | { readonly status: 'rejected'; readonly reason: unknown }; + +type ToolExecutionResultPromise = Promise; + +type ToolExecutionStreamEvent = + | { readonly type: 'timed'; readonly result: IteratorResult } + | { readonly type: 'timedRejected'; readonly reason: unknown } + | { + readonly type: 'finalized'; + readonly promise: ToolExecutionResultPromise; + readonly settled: SettledToolExecutionResult; + }; + +export const toolExecutorToolCallDupTypesKey = defineState>( + 'toolExecutor.toolCallDupTypes', + () => new Map(), +); +export const toolExecutorDupTypeTurnIdKey = defineState( + 'toolExecutor.dupTypeTurnId', + () => undefined as number | undefined, +); + +export class AgentToolExecutorService implements IAgentToolExecutorService { + declare readonly _serviceBrand: undefined; + + private readonly beforeExecuteEmitter = new BeforeToolExecuteEmitter(); + readonly onBeforeExecuteTool: Event = this.beforeExecuteEmitter.event; + private readonly willExecuteEmitter = new AsyncEmitter(); + readonly onWillExecuteTool: Event = this.willExecuteEmitter.event; + + readonly hooks = { + onDidExecuteTool: new OrderedHookSlot(), + }; + + private missingToolDescriber: MissingToolDescriber | undefined; + private unavailableToolDescriber: UnavailableToolDescriber | undefined; + private toolCallGuard: ToolCallGuard | undefined; + + recordDupType(toolCallId: string, dupType: ToolCallDupType): void { + this.toolCallDupTypes.set(toolCallId, dupType); + } + + registerToolCallGuard(guard: ToolCallGuard) { + this.toolCallGuard = guard; + return toDisposable(() => { + if (this.toolCallGuard === guard) this.toolCallGuard = undefined; + }); + } + + registerUnavailableToolDescriber(describer: UnavailableToolDescriber) { + this.unavailableToolDescriber = describer; + return toDisposable(() => { + if (this.unavailableToolDescriber === describer) this.unavailableToolDescriber = undefined; + }); + } + + registerMissingToolDescriber(describer: MissingToolDescriber) { + this.missingToolDescriber = describer; + return toDisposable(() => { + if (this.missingToolDescriber === describer) this.missingToolDescriber = undefined; + }); + } + + constructor( + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, + @IEventDispatcher private readonly dispatcher: IEventDispatcher, + @ITelemetryService private readonly telemetry: ITelemetryService, + @IAgentToolResultTruncationService + private readonly resultTruncation: IAgentToolResultTruncationService, + @IAgentStateService private readonly states: IAgentStateService, + @ILogService private readonly log?: ILogService, + ) { + this.states.contributeState(toolExecutorToolCallDupTypesKey); + this.states.contributeState(toolExecutorDupTypeTurnIdKey); + } + + private get toolCallDupTypes(): Map { + 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[], + options: ToolExecutorExecuteOptions, + ): AsyncIterable { + if (calls.length === 0) return; + if (options.turnId !== this.dupTypeTurnId) { + this.dupTypeTurnId = options.turnId; + this.toolCallDupTypes.clear(); + } + + const preflighted = calls.map((call) => + preflightToolCall( + this.toolRegistry, + call, + this.toolCallGuard, + this.unavailableToolDescriber, + this.missingToolDescriber, + this.log, + ), + ); + const preparedTasks: Array<{ + task: ToolExecutionTask; + call: PreflightedToolCall; + resolvedAccesses?: ToolAccesses; + stopBatchAfterThis?: boolean; + }> = []; + + let stopBatch = false; + for (const call of preflighted) { + if (stopBatch) { + const skipped = this.prepareSkippedToolCall(call, options); + preparedTasks.push({ ...skipped, call }); + continue; + } + + const prepared = await this.prepareToolCall(call, calls, options); + preparedTasks.push({ + task: prepared.task, + call, + resolvedAccesses: prepared.resolvedAccesses, + stopBatchAfterThis: prepared.stopBatchAfterThis, + }); + if (prepared.stopBatchAfterThis === true) { + stopBatch = true; + } + } + + const timedResults = this.executeBatch( + preparedTasks.map(({ task }) => task), + options.signal, + )[Symbol.asyncIterator](); + let nextTimed: Promise> | undefined = timedResults.next(); + const finalizations = new Set(); + + try { + while (nextTimed !== undefined || finalizations.size > 0) { + const candidates: Array> = []; + if (nextTimed !== undefined) { + candidates.push( + nextTimed.then( + (result): ToolExecutionStreamEvent => ({ type: 'timed', result }), + (reason): ToolExecutionStreamEvent => ({ type: 'timedRejected', reason }), + ), + ); + } + for (const promise of finalizations) { + candidates.push( + promise.then((settled): ToolExecutionStreamEvent => ({ + type: 'finalized', + promise, + settled, + })), + ); + } + + const event = await Promise.race(candidates); + if (event.type === 'timedRejected') { + throw event.reason; + } + if (event.type === 'timed') { + if (event.result.done === true) { + nextTimed = undefined; + continue; + } + + const finalization = this.finalizeTimedResult( + preparedTasks[event.result.value.index]!, + event.result.value, + options, + ).then( + (value): SettledToolExecutionResult => ({ status: 'fulfilled', value }), + (reason): SettledToolExecutionResult => ({ status: 'rejected', reason }), + ); + finalizations.add(finalization); + nextTimed = timedResults.next(); + continue; + } + + finalizations.delete(event.promise); + if (event.settled.status === 'rejected') throw event.settled.reason; + yield event.settled.value; + } + } finally { + await timedResults.return?.(); + await Promise.allSettled(finalizations); + } + } + + private async finalizeTimedResult( + prepared: { + readonly call: PreflightedToolCall; + readonly resolvedAccesses?: ToolAccesses; + }, + timedResult: TimedToolResult, + options: ToolExecutorExecuteOptions, + ): Promise { + const { call } = prepared; + const rawResult = timedResult.result; + const finalized = await this.finalizeToolResult( + call, + rawResult, + options, + timedResult.outcome, + prepared.resolvedAccesses, + ); + + this.dispatchToolResult(call, finalized, options); + this.trackToolCall(call, finalized, timedResult.durationMs, options); + + return { + toolCallId: call.toolCall.id, + toolName: call.toolName, + result: finalized, + }; + } + + private trackToolCall( + call: PreflightedToolCall, + result: ToolResult, + durationMs: number, + options: ToolExecutorExecuteOptions, + ): void { + const outcome = toolTelemetryOutcome(result); + const toolCallId = call.toolCall.id; + const dupType = this.toolCallDupTypes.get(toolCallId) ?? 'normal'; + this.toolCallDupTypes.delete(toolCallId); + const properties: ToolCallEvent = { + turn_id: options.turnId, + tool_call_id: toolCallId, + tool_name: call.toolName, + outcome, + duration_ms: durationMs, + dup_type: dupType, + trace_id: options.trace?.traceId, + }; + if (result.isError === true) properties['error_type'] = toolTelemetryErrorType(outcome); + this.telemetry.track2('tool_call', properties); + } + + private async prepareToolCall( + call: PreflightedToolCall, + allCalls: readonly ToolCall[], + options: ToolExecutorExecuteOptions, + ): Promise<{ + task: ToolExecutionTask; + resolvedAccesses?: ToolAccesses; + stopBatchAfterThis?: boolean; + }> { + const settleError = ( + args: unknown, + output: string, + outcome: Exclude, + displayFields?: ToolCallDisplayFields, + ): { task: ToolExecutionTask } => { + this.dispatchToolCall(call, args, options, displayFields); + return { + task: makeResolvedTask(makeErrorToolResult(call, args, output), outcome), + }; + }; + + const settleSynthetic = ( + args: unknown, + result: ExecutableToolResult, + outcome: Exclude, + displayFields?: ToolCallDisplayFields, + ): { + task: ToolExecutionTask; + stopBatchAfterThis?: boolean; + } => { + const toolResult = this.normalizeAndMergeResult(result, call.toolName, undefined); + this.dispatchToolCall(call, args, options, displayFields); + return { + task: makeResolvedTask( + { + toolCall: call.toolCall, + toolName: call.toolName, + args, + result: toolResult, + stopTurn: toolResult.stopTurn === true, + }, + outcome, + ), + stopBatchAfterThis: toolResult.stopBatchAfterThis ?? toolResult.stopTurn, + }; + }; + + if (call.kind === 'rejected') { + return settleError(call.args, call.output, 'preflight-rejected'); + } + + let execution: ToolExecution; + try { + execution = await call.tool.resolveExecution(call.args); + } catch (error) { + const output = + error instanceof PathSecurityError + ? error.message + : `Tool "${call.toolName}" failed to resolve execution: ${errorMessage(error)}`; + return settleError(call.args, output, 'resolution-failed'); + } + + const displayFields = toolCallDisplayFieldsFromExecution(execution); + + if (options.signal.aborted) { + return settleError( + call.args, + abortedToolOutput(call.toolName, options.signal.reason), + 'aborted', + displayFields, + ); + } + + if (execution.isError === true) { + return settleSynthetic(call.args, execution, 'synthetic', displayFields); + } + + const beforeContext = buildBeforeExecuteContext(call, execution, allCalls, options); + const decision = await this.beforeExecuteEmitter.fireBeforeExecute(beforeContext); + + if (decision?.veto !== undefined) { + return settleSynthetic(call.args, decision.veto, 'vetoed', displayFields); + } + + const executionMetadata = decision?.executionMetadata; + + await this.willExecuteEmitter.fireAsync( + { + turnId: options.turnId, + toolCall: call.toolCall, + execution, + args: call.args, + }, + options.signal, + ); + + this.dispatchToolCall(call, call.args, options, displayFields); + + return { + task: { + accesses: execution.accesses ?? ToolAccesses.all(), + execute: async (taskSignal) => + this.runSingleExecution(call, execution, executionMetadata, options, taskSignal), + }, + resolvedAccesses: execution.accesses, + stopBatchAfterThis: execution.stopBatchAfterThis, + }; + } + + private prepareSkippedToolCall( + call: PreflightedToolCall, + options: ToolExecutorExecuteOptions, + ): { task: ToolExecutionTask } { + const output = 'Tool skipped because a previous tool call stopped the turn.'; + this.dispatchToolCall(call, call.args, options); + return { + task: makeResolvedTask(makeErrorToolResult(call, call.args, output), 'skipped'), + }; + } + + private async *executeBatch( + tasks: ToolExecutionTask[], + signal: AbortSignal, + ): AsyncIterable { + const scheduler = new ToolScheduler(); + const allResults: Array> = []; + const pendingResults = new Map>(); + + for (let index = 0; index < tasks.length; index += 1) { + const task = tasks[index]!; + const pendingResult = scheduler.add({ + accesses: task.accesses, + start: async () => { + const startedAt = Date.now(); + return { + result: task.execute(signal).then(({ result, outcome }) => ({ + index, + result, + outcome, + durationMs: Math.max(0, Date.now() - startedAt), + })), + }; + }, + }); + allResults.push(pendingResult); + pendingResults.set( + index, + pendingResult.then( + (value): SettledTimedToolResult => ({ status: 'fulfilled', value }), + (reason): SettledTimedToolResult => ({ status: 'rejected', index, reason }), + ), + ); + } + + try { + while (pendingResults.size > 0) { + const settled = await Promise.race(pendingResults.values()); + const index = settled.status === 'fulfilled' ? settled.value.index : settled.index; + pendingResults.delete(index); + if (settled.status === 'rejected') throw settled.reason; + yield settled.value; + } + } finally { + await Promise.allSettled(allResults); + } + } + + private async runSingleExecution( + call: RunnableToolCall, + execution: RunnableToolExecution, + metadata: unknown, + options: ToolExecutorExecuteOptions, + signal: AbortSignal, + ): Promise { + if (signal.aborted) { + return { + result: makeErrorToolResult( + call, + call.args, + abortedToolOutput(call.toolName, signal.reason), + ).result, + outcome: 'aborted', + }; + } + + let rawResult: ExecutableToolResult; + try { + const executePromise = execution.execute({ + turnId: options.turnId, + toolCallId: call.toolCall.id, + trace: options.trace, + metadata, + signal, + steerSignal: options.steerSignal, + onUpdate: (update) => { + if (signal.aborted) return; + this.dispatchToolProgress(call, update, options); + }, + }); + rawResult = await raceWithAbortGrace(executePromise, signal, call.toolName); + } catch (error) { + const aborted = isAbortError(error) || signal.aborted; + const output = aborted + ? abortedToolOutput(call.toolName, signal.reason) + : `Tool "${call.toolName}" failed: ${errorMessage(error)}`; + return { + result: makeErrorToolResult(call, call.args, output).result, + outcome: 'executed', + }; + } + + return { + result: this.normalizeAndMergeResult(rawResult, call.toolName, execution), + outcome: 'executed', + }; + } + + private normalizeAndMergeResult( + rawResult: unknown, + toolName: string, + execution: RunnableToolExecution | undefined, + ): ToolResult { + const coerced = coerceToolResult(rawResult, toolName); + const normalized = normalizeToolResult(coerced); + return { + ...normalized, + description: execution?.description ?? normalized.description, + display: execution?.display ?? normalized.display, + approvalRule: execution?.approvalRule, + stopBatchAfterThis: normalized.stopBatchAfterThis ?? execution?.stopBatchAfterThis, + delivery: coerced.delivery, + }; + } + + private dispatchToolCall( + call: PreflightedToolCall, + args: unknown, + options: ToolExecutorExecuteOptions, + displayFields?: ToolCallDisplayFields, + ): void { + void this.dispatcher.dispatch( + new ToolCallStarted({ + agentId: this.scopeContext.agentId, + turnId: options.turnId, + toolCallId: call.toolCall.id, + name: call.toolName, + args, + description: displayFields?.description, + display: displayFields?.display, + }), + ); + options.onToolCall?.({ + toolCallId: call.toolCall.id, + name: call.toolName, + args, + display: displayFields?.display, + }); + } + + private dispatchToolResult( + call: PreflightedToolCall, + result: ToolResult, + options: ToolExecutorExecuteOptions, + ): void { + void this.dispatcher.dispatch( + new ToolResultEvent({ + agentId: this.scopeContext.agentId, + turnId: options.turnId, + toolCallId: call.toolCall.id, + output: result.output, + isError: result.isError, + }), + ); + } + + private dispatchToolProgress( + call: RunnableToolCall, + update: ToolUpdate, + options: ToolExecutorExecuteOptions, + ): void { + void this.dispatcher.dispatch( + new ToolProgress({ + agentId: this.scopeContext.agentId, + turnId: options.turnId, + toolCallId: call.toolCall.id, + update, + }), + ); + } + + private async finalizeToolResult( + call: PreflightedToolCall, + result: ToolResult, + options: ToolExecutorExecuteOptions, + outcome: ToolExecutionOutcome, + resolvedAccesses?: ToolAccesses, + ): Promise { + const didCtx: ToolDidExecuteContext = { + turnId: options.turnId, + signal: options.signal, + trace: options.trace, + toolCall: call.toolCall, + toolCalls: [call.toolCall], + tool: call.kind === 'runnable' ? call.tool : undefined, + args: call.args, + outcome, + accesses: resolvedAccesses, + result: result as ExecutableToolResult, + }; + + try { + await this.hooks.onDidExecuteTool.run(didCtx); + } catch (error) { + const aborted = isAbortError(error) || options.signal.aborted; + const output = aborted + ? `Tool "${call.toolName}" aborted during onDidExecuteTool hook.` + : `onDidExecuteTool hook failed for "${call.toolName}": ${errorMessage(error)}`; + return { + output, + isError: true, + description: result.description, + display: result.display, + approvalRule: result.approvalRule, + }; + } + + const coercedResult = coerceToolResult(didCtx.result, call.toolName); + const effectiveResult = normalizeToolResult(coercedResult); + const finalResult: ToolResult = { + ...effectiveResult, + description: result.description, + display: result.display, + approvalRule: result.approvalRule, + stopTurn: + result.stopTurn === true || + didCtx.stopTurn === true || + effectiveResult.stopTurn === true, + stopBatchAfterThis: result.stopBatchAfterThis, + delivery: coercedResult.delivery, + }; + return this.resultTruncation.truncateForModel({ + toolName: call.toolName, + toolCallId: call.toolCall.id, + result: finalResult, + }); + } +} + +interface RunnableToolCall { + readonly kind: 'runnable'; + readonly toolCall: ToolCall; + readonly toolName: string; + readonly tool: ExecutableTool; + readonly args: unknown; +} + +interface RejectedToolCall { + readonly kind: 'rejected'; + readonly toolCall: ToolCall; + readonly toolName: string; + readonly args: unknown; + readonly output: string; +} + +type PreflightedToolCall = RunnableToolCall | RejectedToolCall; + +interface PreparedToolResult { + readonly toolCall: ToolCall; + readonly toolName: string; + readonly args: unknown; + readonly result: ToolResult; + readonly stopTurn?: boolean; +} + +type ToolCallDisplayFields = { description?: string | undefined; display?: ToolInputDisplay | undefined }; + +function buildBeforeExecuteContext( + call: RunnableToolCall, + execution: RunnableToolExecution, + allCalls: readonly ToolCall[], + options: ToolExecutorExecuteOptions, +): ResolvedToolExecutionHookContext { + return { + turnId: options.turnId, + signal: options.signal, + trace: options.trace, + toolCall: call.toolCall, + toolCalls: allCalls, + tool: call.tool, + args: call.args, + execution, + }; +} + +function preflightToolCall( + toolRegistry: IAgentToolRegistryService, + toolCall: ToolCall, + guard: ToolCallGuard | undefined, + describeUnavailableTool: UnavailableToolDescriber | undefined, + describeMissingTool: MissingToolDescriber | undefined, + log?: ILogService, +): PreflightedToolCall { + const toolName = toolCall.name; + const parsedArgs = parseToolCallArguments(toolCall.arguments); + if (parsedArgs.parseFailed) { + log?.debug('tool args JSON parse failed', { + toolName, + toolCallId: toolCall.id, + rawLength: typeof toolCall.arguments === 'string' ? toolCall.arguments.length : 0, + error: parsedArgs.error, + }); + } + const tool = toolRegistry.resolve(toolName); + if (tool === undefined) { + return { + kind: 'rejected', + toolCall, + toolName, + args: parsedArgs.data, + output: describeMissingTool?.(toolName) ?? `Tool "${toolName}" not found`, + }; + } + const source = toolRegistry.list().find((entry) => entry.name === toolName)?.source ?? 'builtin'; + const denied = guard?.({ name: toolName, source }); + if (denied !== undefined) { + return { + kind: 'rejected', + toolCall, + toolName, + args: parsedArgs.data, + output: denied, + }; + } + const unavailable = describeUnavailableTool?.(toolName); + if (unavailable !== undefined) { + return { + kind: 'rejected', + toolCall, + toolName, + args: parsedArgs.data, + output: unavailable, + }; + } + const validationError = validateExecutableToolArgs(tool, parsedArgs.data); + if (validationError !== null) { + return { + kind: 'rejected', + toolCall, + toolName, + args: parsedArgs.data, + output: `Invalid args for tool "${toolName}": ${validationError}`, + }; + } + return { kind: 'runnable', toolCall, toolName, tool, args: parsedArgs.data }; +} + +function validateExecutableToolArgs(tool: ExecutableTool, args: unknown): string | null { + const schema = tool.parameters; + let cached = validators.get(tool); + if (cached === undefined || cached.schema !== schema) { + try { + cached = { schema, validator: compileToolArgsValidator(schema) }; + validators.set(tool, cached); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + } + return validateToolArgs(cached.validator, args as JsonType); +} + +function toolCallDisplayFieldsFromExecution( + execution: ToolExecution, +): ToolCallDisplayFields | undefined { + if (execution.isError === true) return undefined; + const description = execution.description; + const display = execution.display; + return { + description: description !== undefined && description.length > 0 ? description : undefined, + display, + }; +} + +function makeResolvedTask( + result: PreparedToolResult, + outcome: ToolExecutionOutcome, +): ToolExecutionTask { + return { + accesses: ToolAccesses.none(), + execute: async () => ({ result: result.result, outcome }), + }; +} + +function makeErrorToolResult( + call: PreflightedToolCall, + args: unknown, + output: string, +): PreparedToolResult { + return { + toolCall: call.toolCall, + toolName: call.toolName, + args, + result: { output, isError: true }, + }; +} + +function coerceToolResult(value: unknown, toolName: string): ExecutableToolResult { + if (value === null || value === undefined) { + return { output: `Tool "${toolName}" returned no result.`, isError: true }; + } + if (typeof value !== 'object') { + return { + output: `Tool "${toolName}" returned a ${typeof value} instead of a tool result.`, + isError: true, + }; + } + const candidate = value as { output?: unknown }; + if (typeof candidate.output !== 'string' && !Array.isArray(candidate.output)) { + return { + output: `Tool "${toolName}" returned a result with a missing or malformed "output" field.`, + isError: true, + }; + } + return value as ExecutableToolResult; +} + +function normalizeToolResult(result: ExecutableToolResult): ToolResult { + let output: ToolResult['output']; + if (typeof result.output === 'string') { + output = result.output.length > 0 ? result.output : TOOL_OUTPUT_EMPTY; + } else if (result.output.length === 0) { + output = TOOL_OUTPUT_EMPTY; + } else { + const hasMediaBlock = result.output.some(isMediaContentPart); + if (hasMediaBlock) { + const hasNonEmptyText = result.output.some( + (part) => part.type === 'text' && part.text.length > 0, + ); + output = hasNonEmptyText + ? result.output + : [{ type: 'text', text: TOOL_OUTPUT_NON_TEXT }, ...result.output]; + } else { + const textJoined = result.output + .filter((part): part is Extract => part.type === 'text') + .map((part) => part.text) + .join(''); + output = textJoined.length > 0 ? textJoined : TOOL_OUTPUT_EMPTY; + } + } + const base: { + output: ToolResult['output']; + stopTurn?: boolean; + stopTurnReason?: string; + truncated?: true; + note?: string; + spill?: ToolResultSpill; + spillExempt?: true; + } = { + output, + stopTurn: result.stopTurn, + spill: result.spill, + spillExempt: result.spillExempt, + }; + if (result.stopTurnReason !== undefined) base.stopTurnReason = result.stopTurnReason; + if (result.truncated === true) base.truncated = true; + if (typeof result.note === 'string' && result.note.length > 0) base.note = result.note; + if (result.isError === true) { + return { + ...base, + isError: true, + }; + } + return base; +} + +function toolTelemetryOutcome(result: ToolResult): 'success' | 'error' | 'cancelled' { + if (result.isError !== true) return 'success'; + const text = toolOutputText(result.output).toLowerCase(); + return text.includes('aborted') || + text.includes('cancelled') || + text.includes('manually interrupted') + ? 'cancelled' + : 'error'; +} + +function toolTelemetryErrorType(outcome: 'success' | 'error' | 'cancelled'): 'cancelled' | 'error' { + if (outcome === 'cancelled') return 'cancelled'; + return 'error'; +} + +function toolOutputText(output: ToolResult['output']): string { + if (typeof output === 'string') return output; + return output + .filter((part): part is Extract => part.type === 'text') + .map((part) => part.text) + .join(''); +} + +function isMediaContentPart(part: ContentPart): boolean { + return part.type === 'image_url' || part.type === 'audio_url' || part.type === 'video_url'; +} + +export function abortedToolOutput(toolName: string, reason: unknown): string { + if (isUserCancellation(reason)) { + return `The user manually interrupted "${toolName}" (and anything else running at the same time). This was a deliberate user action, not a system error, timeout, or capacity limit. Do not retry automatically or guess at the cause — wait for the user's next instruction.`; + } + return `Tool "${toolName}" was aborted`; +} + +async function raceWithAbortGrace( + executePromise: Promise, + signal: AbortSignal, + toolName: string, +): Promise { + let graceTimer: ReturnType | undefined; + let onAbort: (() => void) | undefined; + + const graceSentinel: Promise = new Promise((resolve) => { + const armTimer = (): void => { + graceTimer = setTimeout(() => { + resolve({ + output: abortedToolOutput(toolName, signal.reason), + isError: true, + } as unknown as Result); + }, ABORT_GRACE_MS); + }; + if (signal.aborted) { + armTimer(); + } else { + onAbort = armTimer; + signal.addEventListener('abort', onAbort, { once: true }); + } + }); + + try { + return await Promise.race([executePromise, graceSentinel]); + } finally { + if (graceTimer !== undefined) clearTimeout(graceTimer); + if (onAbort !== undefined) { + try { + signal.removeEventListener('abort', onAbort); + } catch { + } + } + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +registerScopedService( + LifecycleScope.Agent, + IAgentToolExecutorService, + AgentToolExecutorService, + ScopeActivation.OnScopeCreated, + 'toolExecutor', +); diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolHooks.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolHooks.ts new file mode 100644 index 0000000000000000000000000000000000000000..113ede36e578af8af96209419d7584a7badb4732 --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolHooks.ts @@ -0,0 +1,59 @@ +import type { IWaitUntil } from '#/_base/event'; +import type { ToolCall } from '#human/llm/message'; +import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; + +import type { + ExecutableTool, + ExecutableToolResult, + RunnableToolExecution, + ToolAccesses, +} from '#/tool/toolContract'; + +export interface ToolExecutionHookContext { + readonly turnId: number; + readonly signal: AbortSignal; + readonly trace?: LLMRequestTrace; + readonly toolCall: ToolCall; + readonly toolCalls: readonly ToolCall[]; + readonly tool?: ExecutableTool | undefined; + readonly args: unknown; +} + +export interface ResolvedToolExecutionHookContext extends ToolExecutionHookContext { + readonly execution: RunnableToolExecution; +} + +export interface BeforeExecuteDecision { + readonly veto?: ExecutableToolResult; + readonly executionMetadata?: unknown; +} + +export interface BeforeToolExecuteEvent extends ResolvedToolExecutionHookContext { + veto(result: ExecutableToolResult): void; + allow(): void; + pass(metadata?: unknown): void; + waitUntil(factory: () => Promise): void; +} + +export interface WillExecuteToolEvent extends IWaitUntil { + readonly turnId: number; + readonly toolCall: ToolCall; + readonly execution: RunnableToolExecution; + readonly args: unknown; +} + +export type ToolExecutionOutcome = + | 'executed' + | 'preflight-rejected' + | 'resolution-failed' + | 'vetoed' + | 'aborted' + | 'synthetic' + | 'skipped'; + +export interface ToolDidExecuteContext extends ToolExecutionHookContext { + readonly outcome: ToolExecutionOutcome; + readonly accesses?: ToolAccesses; + result: ExecutableToolResult; + stopTurn?: boolean; +} diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolScheduler.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolScheduler.ts new file mode 100644 index 0000000000000000000000000000000000000000..64be16d2a3501218f57eb8d6e8f9b581a2cb6031 --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolScheduler.ts @@ -0,0 +1,99 @@ +import { ToolAccesses } from '#/tool/toolContract'; + +export interface ToolCallTask { + readonly accesses: ToolAccesses; + readonly start: () => Promise<{ readonly result: Promise }>; +} + +interface ScheduledToolCallTask extends ToolCallTask { + readonly result: ControlledPromise; +} + +type ControlledPromise = Promise & { + readonly resolve: (value: Result | PromiseLike) => void; + readonly reject: (reason?: unknown) => void; +}; + +export class ToolScheduler { + private readonly activeTasks: Array> = []; + private queuedTasks: Array> = []; + + add(task: ToolCallTask): Promise { + const result = createControlledPromise(); + void result.catch(() => undefined); + + const scheduledTask: ScheduledToolCallTask = { ...task, result }; + if (this.isBlocked(task, this.queuedTasks)) { + this.queuedTasks.push(scheduledTask); + } else { + this.start(scheduledTask); + } + + return result; + } + + private isBlocked( + task: ToolCallTask, + queuedBefore: readonly ToolCallTask[], + ): boolean { + return ( + this.conflictsWithAny(task, this.activeTasks) || this.conflictsWithAny(task, queuedBefore) + ); + } + + private conflictsWithAny( + task: ToolCallTask, + candidates: readonly ToolCallTask[], + ): boolean { + return candidates.some((candidate) => + ToolAccesses.conflict(task.accesses, candidate.accesses), + ); + } + + private start(task: ScheduledToolCallTask): void { + this.activeTasks.push(task); + let started: Promise<{ readonly result: Promise }>; + try { + started = task.start(); + } catch (error) { + task.result.reject(error); + this.finish(task); + return; + } + + void started + .then(({ result }) => result) + .then(task.result.resolve, task.result.reject) + .finally(() => { + this.finish(task); + }); + } + + private finish(task: ScheduledToolCallTask): void { + const index = this.activeTasks.indexOf(task); + if (index >= 0) this.activeTasks.splice(index, 1); + this.startQueuedTasks(); + } + + private startQueuedTasks(): void { + const stillQueued: Array> = []; + for (const task of this.queuedTasks) { + if (this.isBlocked(task, stillQueued)) { + stillQueued.push(task); + } else { + this.start(task); + } + } + this.queuedTasks = stillQueued; + } +} + +function createControlledPromise(): ControlledPromise { + let resolve!: (value: Result | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }) as ControlledPromise; + return Object.assign(promise, { resolve, reject }); +} diff --git a/packages/agent-core-v2/src/agent/toolPolicy/configSection.ts b/packages/agent-core-v2/src/agent/toolPolicy/configSection.ts new file mode 100644 index 0000000000000000000000000000000000000000..61df6360db5e6c5337e793e0d22659b1e5583844 --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolPolicy/configSection.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +import { registerConfigSection } from '#/app/config/configSectionContributions'; + +export const TOOLS_SECTION = 'tools'; + +export const ToolsConfigSchema = z.object({ + enabled: z.array(z.string()).optional(), + disabled: z.array(z.string()).optional(), +}); + +export type ToolsConfig = z.infer; + +registerConfigSection(TOOLS_SECTION, ToolsConfigSchema); diff --git a/packages/agent-core-v2/src/agent/toolPolicy/evaluate.ts b/packages/agent-core-v2/src/agent/toolPolicy/evaluate.ts new file mode 100644 index 0000000000000000000000000000000000000000..ae563141578d235a823456a40c81d2348791ca31 --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolPolicy/evaluate.ts @@ -0,0 +1,106 @@ +import picomatch from 'picomatch'; + +import { isMcpToolName, type ToolSource } from '#/tool/toolContract'; + +export interface ToolActivationPolicy { + readonly tools?: readonly string[]; + readonly disallowedTools?: readonly string[]; +} + +export function isToolActive( + policy: ToolActivationPolicy, + name: string, + source: ToolSource = 'builtin', +): boolean { + if (policy.tools !== undefined) { + const allowed = + source !== 'mcp' + ? policy.tools.includes(name) + : policy.tools + .filter((pattern) => isMcpToolName(pattern)) + .some((pattern) => picomatch.isMatch(name, pattern)); + if (!allowed) return false; + } + if (policy.disallowedTools === undefined) return true; + if (source !== 'mcp') return !policy.disallowedTools.includes(name); + return !policy.disallowedTools + .filter((pattern) => isMcpToolName(pattern)) + .some((pattern) => picomatch.isMatch(name, pattern)); +} + +export interface GlobalToolsPolicy { + readonly enabled?: readonly string[]; + readonly disabled?: readonly string[]; +} + +export interface ToolPolicyLayers { + readonly workspaceDisabledTools?: readonly string[]; + readonly profile: ToolActivationPolicy; + readonly global?: GlobalToolsPolicy; + readonly sessionDisabledTools?: readonly string[]; +} + +export function isToolActiveComposed( + layers: ToolPolicyLayers, + name: string, + source: ToolSource = 'builtin', +): boolean { + return ( + isToolActive({ disallowedTools: layers.workspaceDisabledTools }, name, source) && + isToolActive(layers.profile, name, source) && + isToolActive( + { + tools: layers.global?.enabled?.length ? layers.global.enabled : undefined, + disallowedTools: layers.global?.disabled, + }, + name, + source, + ) && + isToolActive({ disallowedTools: layers.sessionDisabledTools }, name, source) + ); +} + +export function resolveActiveToolNames( + policy: ToolActivationPolicy, +): readonly string[] | undefined { + if (policy.tools === undefined) return undefined; + return policy.tools.filter((name) => + isToolActive(policy, name, isMcpToolName(name) ? 'mcp' : 'builtin'), + ); +} + +export type InactiveToolPatternKind = 'wildcard-not-mcp' | 'incomplete-mcp-name' | 'unknown-tool'; + +export interface InactiveToolPattern { + readonly pattern: string; + readonly kind: InactiveToolPatternKind; +} + +const GLOB_MAGIC = /[*?[\]{}]/; + +export function literalToolNames(patterns: readonly string[]): string[] { + return patterns.filter((pattern) => !isMcpToolName(pattern) && !GLOB_MAGIC.test(pattern)); +} + +export function findInactiveToolPatterns( + patterns: readonly string[], + isKnownToolName?: (name: string) => boolean, +): InactiveToolPattern[] { + const issues: InactiveToolPattern[] = []; + for (const pattern of patterns) { + if (isMcpToolName(pattern)) { + if (!GLOB_MAGIC.test(pattern) && !pattern.slice('mcp__'.length).includes('__')) { + issues.push({ pattern, kind: 'incomplete-mcp-name' }); + } + continue; + } + if (GLOB_MAGIC.test(pattern)) { + issues.push({ pattern, kind: 'wildcard-not-mcp' }); + continue; + } + if (isKnownToolName !== undefined && !isKnownToolName(pattern)) { + issues.push({ pattern, kind: 'unknown-tool' }); + } + } + return issues; +} diff --git a/packages/agent-core-v2/src/agent/toolPolicy/toolPolicy.ts b/packages/agent-core-v2/src/agent/toolPolicy/toolPolicy.ts new file mode 100644 index 0000000000000000000000000000000000000000..5843db376aa336de8b3c09e965aac2177485a105 --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolPolicy/toolPolicy.ts @@ -0,0 +1,20 @@ +import { createDecorator } from '#/_base/di/instantiation'; +import type { ToolSource } from '#/tool/toolContract'; + +import type { ToolActivationPolicy } from './evaluate'; + +export interface IAgentToolPolicyService { + readonly _serviceBrand: undefined; + + isToolActive(name: string, source?: ToolSource): boolean; + isToolActiveForDisclosure(name: string, source?: ToolSource): boolean; + isToolActiveForProfile( + profile: ToolActivationPolicy, + name: string, + source?: ToolSource, + ): boolean; + setSessionDisabledTools(names: readonly string[]): Promise; +} + +export const IAgentToolPolicyService = + createDecorator('agentToolPolicyService'); diff --git a/packages/agent-core-v2/src/agent/toolPolicy/toolPolicyService.ts b/packages/agent-core-v2/src/agent/toolPolicy/toolPolicyService.ts new file mode 100644 index 0000000000000000000000000000000000000000..3c16c69f3d9bcff458a2d727db13261f5340157a --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolPolicy/toolPolicyService.ts @@ -0,0 +1,100 @@ +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IAgentProfileService, ProfileError, ProfileErrors } from '#/agent/profile/profile'; +import { TOOLS_SECTION, type ToolsConfig } from './configSection'; +import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; +import { IConfigService } from '#/app/config/config'; +import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; +import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; +import { SELECT_TOOLS_TOOL_NAME } from '#/agent/toolSelect/toolSelect'; +import type { ToolSource } from '#/tool/toolContract'; + +import { isToolActiveComposed, type ToolActivationPolicy } from './evaluate'; +import { IAgentToolPolicyService } from './toolPolicy'; + +export class AgentToolPolicyService extends Disposable implements IAgentToolPolicyService { + declare readonly _serviceBrand: undefined; + + constructor( + @IAgentProfileService private readonly profile: IAgentProfileService, + @IConfigService private readonly config: IConfigService, + @ISessionToolPolicy private readonly sessionToolPolicy: ISessionToolPolicy, + @ISessionToolPolicyGate private readonly toolPolicyGate: ISessionToolPolicyGate, + @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, + ) { + super(); + this._register( + toolExecutor.registerToolCallGuard(({ name, source }) => { + const active = + name === SELECT_TOOLS_TOOL_NAME + ? this.isToolActiveForDisclosure(name, source) + : this.isToolActive(name, source); + return active + ? undefined + : `Tool "${name}" is disabled by the active tool policy`; + }), + ); + } + + isToolActive(name: string, source: ToolSource = 'builtin'): boolean { + const profile = this.profile.data(); + return this.isToolActiveForProfile( + { + tools: profile.activeToolNames, + disallowedTools: profile.disallowedTools, + }, + name, + source, + ); + } + + isToolActiveForDisclosure(name: string, source: ToolSource = 'builtin'): boolean { + const profile = this.profile.data(); + return isToolActiveComposed( + { + workspaceDisabledTools: this.toolPolicyGate.disabledTools, + profile: { disallowedTools: profile.disallowedTools }, + global: this.config.get(TOOLS_SECTION), + sessionDisabledTools: this.sessionToolPolicy.disabledTools(), + }, + name, + source, + ); + } + + isToolActiveForProfile( + profile: ToolActivationPolicy, + name: string, + source: ToolSource = 'builtin', + ): boolean { + return isToolActiveComposed( + { + workspaceDisabledTools: this.toolPolicyGate.disabledTools, + profile, + global: this.config.get(TOOLS_SECTION), + sessionDisabledTools: this.sessionToolPolicy.disabledTools(), + }, + name, + source, + ); + } + + async setSessionDisabledTools(names: readonly string[]): Promise { + if (this.profile.data().profileName === undefined) { + throw new ProfileError( + ProfileErrors.codes.PROFILE_NOT_BOUND, + 'Cannot set session disabled tools: agent profile is not bound', + ); + } + await this.sessionToolPolicy.setDisabledTools(names); + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentToolPolicyService, + AgentToolPolicyService, + ScopeActivation.OnScopeCreated, + 'toolPolicy', +); diff --git a/packages/agent-core-v2/src/agent/toolRegistry/builtinToolAssemblyService.ts b/packages/agent-core-v2/src/agent/toolRegistry/builtinToolAssemblyService.ts new file mode 100644 index 0000000000000000000000000000000000000000..16a558a9e6c8e98cadfc5c0994177e733c8b718f --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolRegistry/builtinToolAssemblyService.ts @@ -0,0 +1,33 @@ +import { createDecorator } from '#/_base/di/instantiation'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; + +import { AgentToolContribution, getAgentToolContributions } from './toolContribution'; + +export interface IBuiltinToolAssemblyService { + readonly _serviceBrand: undefined; +} + +export const IBuiltinToolAssemblyService = createDecorator( + 'builtinToolAssemblyService', +); + +export class BuiltinToolAssemblyService extends Service implements IBuiltinToolAssemblyService { + declare readonly _serviceBrand: undefined; + + constructor() { + super(); + for (const record of getAgentToolContributions()) { + this.provide(AgentToolContribution, record); + } + } +} + +registerScopedService( + LifecycleScope.App, + IBuiltinToolAssemblyService, + BuiltinToolAssemblyService, + ScopeActivation.OnScopeCreated, + 'toolRegistry', +); diff --git a/packages/agent-core-v2/src/agent/toolRegistry/toolContribution.ts b/packages/agent-core-v2/src/agent/toolRegistry/toolContribution.ts new file mode 100644 index 0000000000000000000000000000000000000000..b7e850620ed7ef1cac308c8f3a6346f446ed594f --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolRegistry/toolContribution.ts @@ -0,0 +1,76 @@ +import type { ServiceIdentifier, ServicesAccessor } from '#/_base/di/instantiation'; +import { collection } from '#/_base/di/collection'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, overrideScopedService, registerScopedService } from '#/_base/di/scope'; +import type { + AgentTool, + ToolDisclosure, + ToolSource, +} from '#/tool/toolContract'; +import type { RuntimeCapability } from '#/runtime/runtime'; + +export type AnyAgentTool = AgentTool; + +export type AgentToolCtor = new (...args: any[]) => T; + +export interface AgentToolContributionOptions { + readonly name: string; + readonly source?: ToolSource; + readonly disclosure?: ToolDisclosure; + readonly when?: (accessor: ServicesAccessor) => boolean; + readonly requiredRuntimeCapabilities?: readonly RuntimeCapability[]; + readonly domain?: string; +} + +export interface AgentToolContribution { + readonly id: ServiceIdentifier; + readonly ctor: AgentToolCtor; + readonly options: AgentToolContributionOptions; +} + +export const AgentToolContribution = collection('agent-tool'); + +const _agentToolContributions: AgentToolContribution[] = []; + +export function registerAgentToolService( + id: ServiceIdentifier, + ctor: AgentToolCtor, + options: AgentToolContributionOptions, +): void { + registerScopedService( + LifecycleScope.Agent, + id, + ctor, + ScopeActivation.OnDemand, + options.domain ?? 'unknown', + ); + _agentToolContributions.push({ id, ctor, options }); +} + +export function overrideAgentToolService( + id: ServiceIdentifier, + ctor: AgentToolCtor, + options: AgentToolContributionOptions, +): void { + overrideScopedService( + LifecycleScope.Agent, + id, + ctor, + ScopeActivation.OnDemand, + options.domain ?? 'unknown', + ); + const index = _agentToolContributions.findIndex((contribution) => contribution.id === id); + if (index === -1) { + _agentToolContributions.push({ id, ctor, options }); + } else { + _agentToolContributions[index] = { id, ctor, options }; + } +} + +export function getAgentToolContributions(): readonly AgentToolContribution[] { + return _agentToolContributions; +} + +export function _clearAgentToolContributionsForTests(): void { + _agentToolContributions.length = 0; +} diff --git a/packages/agent-core-v2/src/agent/toolRegistry/toolRegistry.ts b/packages/agent-core-v2/src/agent/toolRegistry/toolRegistry.ts new file mode 100644 index 0000000000000000000000000000000000000000..10276a7859dec203cabad71b5eab1fbfa6faf7dc --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolRegistry/toolRegistry.ts @@ -0,0 +1,29 @@ +import { createDecorator } from '#/_base/di/instantiation'; +import { type IDisposable } from '#/_base/di/lifecycle'; +import type { + ExecutableTool, + ToolDisclosure, + ToolInfo, + ToolSource, +} from '#/tool/toolContract'; + +export interface ToolRegistrationOptions { + readonly source?: ToolSource; + readonly disclosure?: ToolDisclosure; +} + +export interface ToolReference { + readonly name: string; + readonly source: ToolSource; +} + +export interface IAgentToolRegistryService { + readonly _serviceBrand: undefined; + + register(tool: ExecutableTool, options?: ToolRegistrationOptions): IDisposable; + list(): readonly ToolInfo[]; + listReferences(): readonly ToolReference[]; + resolve(name: string): ExecutableTool | undefined; +} + +export const IAgentToolRegistryService = createDecorator('agentToolRegistryService'); diff --git a/packages/agent-core-v2/src/agent/toolRegistry/toolRegistryService.ts b/packages/agent-core-v2/src/agent/toolRegistry/toolRegistryService.ts new file mode 100644 index 0000000000000000000000000000000000000000..a643d9921ab01e683e4d17797d7e36dd377d8ab8 --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolRegistry/toolRegistryService.ts @@ -0,0 +1,77 @@ +import { toDisposable, type IDisposable } from "#/_base/di/lifecycle"; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import type { + ExecutableTool, + ToolDisclosure, + ToolInfo, + ToolSource, +} from '#/tool/toolContract'; +import { + IAgentToolRegistryService, + type ToolReference, + type ToolRegistrationOptions, +} from './toolRegistry'; + +import './builtinToolAssemblyService'; + +interface ToolEntry { + readonly tool: ExecutableTool; + readonly source: ToolSource; + readonly disclosure?: ToolDisclosure; +} + +export class AgentToolRegistryService implements IAgentToolRegistryService { + declare readonly _serviceBrand: undefined; + private readonly tools = new Map(); + + register(tool: ExecutableTool, options: ToolRegistrationOptions = {}): IDisposable { + const source = options.source ?? 'builtin'; + const entry: ToolEntry = { tool, source, disclosure: options.disclosure }; + this.unregisterTool(tool.name); + this.tools.set(tool.name, entry); + + return toDisposable(() => { + const current = this.tools.get(tool.name); + if (current !== entry) return; + this.unregisterTool(tool.name); + }); + } + + list(): readonly ToolInfo[] { + return [...this.tools.values()] + .map(({ tool, source, disclosure }) => ({ + name: tool.name, + description: tool.description, + parameters: tool.parameters, + source, + disclosure, + })) + .toSorted((a, b) => a.name.localeCompare(b.name)); + } + + listReferences(): readonly ToolReference[] { + return [...this.tools.entries()] + .map(([name, { source }]) => ({ name, source })) + .toSorted((a, b) => a.name.localeCompare(b.name)); + } + + resolve(name: string): ExecutableTool | undefined { + return this.tools.get(name)?.tool; + } + + private unregisterTool(name: string): ToolEntry | undefined { + const entry = this.tools.get(name); + if (entry === undefined) return undefined; + this.tools.delete(name); + return entry; + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentToolRegistryService, + AgentToolRegistryService, + ScopeActivation.OnScopeCreated, + 'toolRegistry', +); diff --git a/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncation.ts b/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncation.ts new file mode 100644 index 0000000000000000000000000000000000000000..6aa87c15543489115af24c68b452300b7b99e2fe --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncation.ts @@ -0,0 +1,26 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ExecutableToolResult } from '#/tool/toolContract'; + +export interface ToolResultTruncationInput< + T extends ExecutableToolResult = ExecutableToolResult, +> { + readonly toolName: string; + readonly toolCallId: string; + readonly result: T; +} + +export interface IAgentToolResultTruncationService { + readonly _serviceBrand: undefined; + + truncateForModel( + input: ToolResultTruncationInput, + ): Promise; + + isSpillFilePath(path: string): boolean; + + isWireJournalPath(path: string): boolean; +} + +export const IAgentToolResultTruncationService: ServiceIdentifier< + IAgentToolResultTruncationService +> = createDecorator('agentToolResultTruncationService'); diff --git a/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts b/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts new file mode 100644 index 0000000000000000000000000000000000000000..6080b211bcc2f2eaf6c5103438b31ca25ece4c3d --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts @@ -0,0 +1,344 @@ +import { randomUUID } from 'node:crypto'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { + DEFAULT_TOOL_RESULT_MAX_CHARS, + DEFAULT_TOOL_RESULT_MAX_RETAINED_CHARS, + type ExecutableToolResult, +} from '#/tool/toolContract'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { AGENT_WIRE_RECORD_KEY } from '#/wire/record'; +import type { ContentPart } from '#human/llm/message'; +import { IFileSystemStorageService } from '#/persistence/interface/storage'; +import { basename, join, normalize } from 'pathe'; +import { + IAgentToolResultTruncationService, + type ToolResultTruncationInput, +} from './toolResultTruncation'; + +const TOOL_RESULT_PREVIEW_HEAD_CHARS = 4_096; +const TOOL_RESULT_PREVIEW_TAIL_CHARS = 1_024; +const TOOL_RESULT_MAX_LINE_CHARS = 2_000; +const TRUNCATION_MARKER = '[...truncated]'; + +const encoder = new TextEncoder(); + +interface ShapedOutput { + readonly output: ExecutableToolResult['output']; + readonly textChars: number; + readonly hasMedia: boolean; +} + +export class ToolResultTruncationService implements IAgentToolResultTruncationService { + declare readonly _serviceBrand: undefined; + + private readonly storageScope: string; + + constructor( + @IBootstrapService private readonly bootstrap: IBootstrapService, + @IAgentScopeContext agent: IAgentScopeContext, + @IFileSystemStorageService private readonly storage: IFileSystemStorageService, + ) { + this.storageScope = agent.scope('tool-results'); + } + + async truncateForModel( + input: ToolResultTruncationInput, + ): Promise { + const { result } = input; + if (result.spillExempt === true) return result; + + const rawText = persistableToolResultText(result.output); + if (rawText.length <= DEFAULT_TOOL_RESULT_MAX_CHARS) return result; + + const { spill, ...rest } = result; + const retainedText = + rawText.length <= DEFAULT_TOOL_RESULT_MAX_RETAINED_CHARS + ? rawText + : rawText.slice(0, DEFAULT_TOOL_RESULT_MAX_RETAINED_CHARS); + const shaped = shapeOutput(result.output, TOOL_RESULT_MAX_LINE_CHARS); + const totalChars = spill?.totalChars ?? rawText.length; + const suffix = spill?.suffix ?? ''; + + const saved = + spill?.outputPath !== undefined + ? { outputPath: spill.outputPath, preservedChars: totalChars } + : await this.saveToolResult(input.toolName, input.toolCallId, retainedText); + if (saved === undefined) { + const fallback = renderUnpersistedToolResult( + input.toolName, + input.toolCallId, + retainedText, + totalChars, + DEFAULT_TOOL_RESULT_MAX_CHARS, + suffix, + ); + return { ...rest, output: mergeSpillPointer(shaped.output, fallback), truncated: true } as T; + } + + if (shaped.textChars <= DEFAULT_TOOL_RESULT_MAX_CHARS) { + const inlineSuffix = dropInlineSuffixLines(suffix, persistableToolResultText(shaped.output)); + return { + ...rest, + output: appendToToolResultOutput( + shaped.output, + renderAppendedSpillPointer( + saved.outputPath, + saved.preservedChars, + totalChars, + inlineSuffix, + shaped.hasMedia, + ), + ), + truncated: true, + } as T; + } + const pointer = renderPersistedToolResult( + input.toolName, + input.toolCallId, + retainedText, + saved.outputPath, + saved.preservedChars, + totalChars, + DEFAULT_TOOL_RESULT_MAX_CHARS, + suffix, + shaped.hasMedia, + ); + return { + ...rest, + output: mergeSpillPointer(shaped.output, pointer), + truncated: true, + } as T; + } + + isSpillFilePath(path: string): boolean { + const dir = normalize(join(this.bootstrap.homeDir, this.storageScope)); + const normalized = normalize(path); + return normalized === dir || normalized.startsWith(`${dir}/`); + } + + isWireJournalPath(path: string): boolean { + const sessionsDir = normalize(join(this.bootstrap.homeDir, this.bootstrap.scope('sessions'))); + const normalized = normalize(path); + return normalized.startsWith(`${sessionsDir}/`) && basename(normalized) === AGENT_WIRE_RECORD_KEY; + } + + private async saveToolResult( + toolName: string, + toolCallId: string, + text: string, + ): Promise<{ readonly outputPath: string; readonly preservedChars: number } | undefined> { + try { + const key = `${safeToolResultFileStem(toolName, toolCallId)}-${randomUUID()}.txt`; + await this.storage.write(this.storageScope, key, encoder.encode(text), { atomic: true }); + return { + outputPath: join(this.bootstrap.homeDir, this.storageScope, key), + preservedChars: text.length, + }; + } catch { + return undefined; + } + } +} + +function shapeOutput( + output: ExecutableToolResult['output'], + maxLineChars: number, +): ShapedOutput { + if (typeof output === 'string') { + const shaped = shapeStringPerLine(output, maxLineChars); + return { output: shaped.text, textChars: shaped.text.length, hasMedia: false }; + } + const out: ContentPart[] = []; + let textChars = 0; + let hasMedia = false; + for (const part of output) { + if (part.type === 'text') { + const shaped = shapeStringPerLine(part.text, maxLineChars); + out.push({ type: 'text', text: shaped.text }); + textChars += shaped.text.length; + continue; + } + if (part.type === 'think') { + textChars += part.think.length + (part.encrypted?.length ?? 0); + } else { + hasMedia = true; + } + out.push(part); + } + return { output: out, textChars, hasMedia }; +} + +function shapeStringPerLine( + text: string, + maxLineChars: number, +): { readonly text: string; readonly truncated: boolean } { + let truncated = false; + const lines = text.match(/[^\r\n]*(?:\r\n|[\n\r])|[^\r\n]+/g) ?? []; + const out: string[] = []; + for (const originalLine of lines) { + let line = originalLine; + if (line.length > maxLineChars) { + const lineBreak = /[\r\n]+$/.exec(line)?.[0] ?? ''; + const suffix = TRUNCATION_MARKER + lineBreak; + const effectiveMaxLength = Math.max(maxLineChars, suffix.length); + line = line.slice(0, effectiveMaxLength - suffix.length) + suffix; + truncated = true; + } + out.push(line); + } + return { text: out.join(''), truncated }; +} + +function persistableToolResultText(output: ExecutableToolResult['output']): string { + if (typeof output === 'string') return output; + let text = ''; + for (const part of output) { + if (part.type === 'text') text += part.text; + else if (part.type === 'think') text += part.think; + } + return text; +} + +function mergeSpillPointer( + output: ExecutableToolResult['output'], + pointer: string, +): ExecutableToolResult['output'] { + if (typeof output === 'string') return pointer; + const mediaParts = output.filter((part) => part.type !== 'text' && part.type !== 'think'); + if (mediaParts.length === 0) return pointer; + return [{ type: 'text', text: pointer }, ...mediaParts]; +} + +function renderAppendedSpillPointer( + outputPath: string, + preservedChars: number, + totalChars: number, + suffix: string, + hasMedia: boolean, +): string { + const firstLine = + totalChars > preservedChars + ? `[Per-line truncation occurred; only the first ${String(preservedChars)} characters (of ${String(totalChars)}) were saved to a file.` + : hasMedia + ? '[Per-line truncation occurred; the complete text output was saved to a file (media parts stay attached to this result).' + : '[Per-line truncation occurred; the complete output was saved to a file.'; + const lines = [ + firstLine, + `output_path: ${outputPath}`, + 'next_step: Use Read with output_path to page through the saved output, or Grep to search it.]', + ]; + if (suffix.length > 0) lines.push('', suffix); + return lines.join('\n'); +} + +function appendToToolResultOutput( + output: ExecutableToolResult['output'], + note: string, +): ExecutableToolResult['output'] { + if (typeof output === 'string') { + return output.endsWith('\n') || output.length === 0 ? `${output}${note}` : `${output}\n${note}`; + } + const parts = [...output]; + const last = parts.at(-1); + if (last !== undefined && last.type === 'text') { + parts[parts.length - 1] = { type: 'text', text: `${last.text}\n${note}` }; + } else { + parts.push({ type: 'text', text: note }); + } + return parts; +} + +function renderPersistedToolResult( + toolName: string, + toolCallId: string, + previewText: string, + outputPath: string, + preservedChars: number, + totalChars: number, + maxChars: number, + suffix: string, + hasMedia: boolean, +): string { + const partial = preservedChars < totalChars; + const lines = [ + partial + ? `Tool output exceeded ${String(maxChars)} characters; the first ${String(preservedChars)} characters (of ${String(totalChars)}) were saved to a file.` + : hasMedia + ? `Tool output exceeded ${String(maxChars)} characters; the full text output was saved to a file (media parts stay attached to this result).` + : `Tool output exceeded ${String(maxChars)} characters; the full output was saved to a file.`, + `tool_name: ${toolName}`, + `tool_call_id: ${toolCallId}`, + partial + ? `output_size_chars: ${String(totalChars)} (only the first ${String(preservedChars)} characters were preserved)` + : `output_size_chars: ${String(totalChars)}`, + ]; + if (preservedChars === previewText.length) { + lines.push(`output_size_bytes: ${String(Buffer.byteLength(previewText, 'utf8'))}`); + } + lines.push( + `output_path: ${outputPath}`, + 'next_step: Use Read with output_path to page through the saved output, or Grep to search it.', + ); + appendPreviewLines(lines, previewText); + if (suffix.length > 0) lines.push('', suffix); + return lines.join('\n'); +} + +function renderUnpersistedToolResult( + toolName: string, + toolCallId: string, + previewText: string, + totalChars: number, + maxChars: number, + suffix: string, +): string { + const lines = [ + `Tool output exceeded ${String(maxChars)} characters and could not be saved to a file; only this preview is available.`, + `tool_name: ${toolName}`, + `tool_call_id: ${toolCallId}`, + `output_size_chars: ${String(totalChars)}`, + ]; + appendPreviewLines(lines, previewText); + if (suffix.length > 0) lines.push('', suffix); + return lines.join('\n'); +} + +function appendPreviewLines(lines: string[], previewText: string): void { + const head = previewText.slice(0, TOOL_RESULT_PREVIEW_HEAD_CHARS); + const tailStart = Math.max(head.length, previewText.length - TOOL_RESULT_PREVIEW_TAIL_CHARS); + const tail = previewText.slice(tailStart); + lines.push('', `[preview: chars [0, ${String(head.length)})]`, head); + if (tail !== '') { + if (tailStart > head.length) { + lines.push('', `[elided: chars [${String(head.length)}, ${String(tailStart)})]`); + } + lines.push('', `[preview: chars [${String(tailStart)}, ${String(previewText.length)})]`, tail); + } +} + +function dropInlineSuffixLines(suffix: string, shapedText: string): string { + if (suffix.length === 0) return ''; + const lines = suffix.split('\n'); + if (!lines.some((line) => line.length > 0 && shapedText.includes(line))) return suffix; + return lines + .filter((line) => line.length > 0 && !shapedText.includes(line)) + .join('\n'); +} + +function safeToolResultFileStem(toolName: string, toolCallId: string): string { + const label = `${toolName}-${toolCallId}` + .replace(/[^a-zA-Z0-9._-]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 80); + return label || 'tool-result'; +} + +registerScopedService( + LifecycleScope.Agent, + IAgentToolResultTruncationService, + ToolResultTruncationService, + ScopeActivation.OnScopeCreated, + 'toolResultTruncation', +); diff --git a/packages/agent-core-v2/src/agent/toolSelect/dynamicTools.ts b/packages/agent-core-v2/src/agent/toolSelect/dynamicTools.ts new file mode 100644 index 0000000000000000000000000000000000000000..ab4871bd46b0c32a39927c5c648bc3e4187a712f --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolSelect/dynamicTools.ts @@ -0,0 +1,101 @@ +import type { ContextMessage } from '#/agent/contextMemory/types'; + +export const DYNAMIC_TOOL_SCHEMA_VARIANT = 'dynamic_tool_schema'; + +export const LOADABLE_TOOLS_VARIANT = 'loadable-tools'; + +export function isDynamicToolSchemaMessage(message: ContextMessage): boolean { + return message.tools !== undefined && message.tools.length > 0; +} + +export function isLoadableToolsAnnouncement(message: ContextMessage): boolean { + const origin = message.origin; + if (origin?.kind === 'injection') return origin.variant === LOADABLE_TOOLS_VARIANT; + return origin?.kind === 'system_trigger' && origin.name === LOADABLE_TOOLS_VARIANT; +} + +export function stripDynamicToolContext( + history: readonly ContextMessage[], +): readonly ContextMessage[] { + if (!history.some((m) => isDynamicToolSchemaMessage(m) || isLoadableToolsAnnouncement(m))) { + return history; + } + const out: ContextMessage[] = []; + for (const message of history) { + if (isLoadableToolsAnnouncement(message)) continue; + if (isDynamicToolSchemaMessage(message)) { + const { tools: _tools, ...rest } = message; + void _tools; + if (rest.content.length === 0 && rest.toolCalls.length === 0) continue; + out.push(rest); + continue; + } + out.push(message); + } + return out; +} + +export function collectLoadedDynamicToolNames( + history: readonly ContextMessage[], +): Set { + const names = new Set(); + for (const message of history) { + if (message.tools === undefined) continue; + for (const tool of message.tools) { + names.add(tool.name); + } + } + return names; +} + +const TOOLS_ADDED_BLOCK = /\n?([\s\S]*?)\n?<\/tools_added>/g; +const TOOLS_REMOVED_BLOCK = /\n?([\s\S]*?)\n?<\/tools_removed>/g; + +export function foldAnnouncedToolNames(history: readonly ContextMessage[]): Set { + const announced = new Set(); + for (const message of history) { + if (!isLoadableToolsAnnouncement(message)) continue; + const text = message.content + .map((part) => (part.type === 'text' ? part.text : '')) + .join(''); + for (const name of matchToolNameBlocks(text, TOOLS_REMOVED_BLOCK)) { + announced.delete(name); + } + for (const name of matchToolNameBlocks(text, TOOLS_ADDED_BLOCK)) { + announced.add(name); + } + } + return announced; +} + +function matchToolNameBlocks(text: string, pattern: RegExp): string[] { + const names: string[] = []; + pattern.lastIndex = 0; + for (const match of text.matchAll(pattern)) { + const body = match[1] ?? ''; + for (const line of body.split('\n')) { + const name = line.trim(); + if (name.length > 0) names.push(name); + } + } + return names; +} + +export function renderLoadableToolsAnnouncement( + added: readonly string[], + removed: readonly string[], +): string { + const sections: string[] = []; + if (added.length > 0) { + sections.push(`\n${added.join('\n')}\n`); + } + if (removed.length > 0) { + sections.push(`\n${removed.join('\n')}\n`); + } + sections.push( + 'Use the select_tools tool with exact names to load full tool definitions before calling them. ' + + 'Names listed as removed are no longer loadable — do not select them. ' + + 'Fold all announcements in this conversation in order to get the current list.', + ); + return sections.join('\n\n'); +} diff --git a/packages/agent-core-v2/src/agent/toolSelect/flag.ts b/packages/agent-core-v2/src/agent/toolSelect/flag.ts new file mode 100644 index 0000000000000000000000000000000000000000..7178e3ec332e7617d6772f61d04c235a11936df4 --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolSelect/flag.ts @@ -0,0 +1,16 @@ +import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry'; + +export const TOOL_SELECT_FLAG_ID = 'tool-select'; +export const TOOL_SELECT_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_TOOL_SELECT'; + +export const toolSelectFlag: FlagDefinitionInput = { + id: TOOL_SELECT_FLAG_ID, + title: 'Tool select (progressive tool disclosure)', + description: + 'Keep MCP tool schemas out of the immutable top-level tools[]; the model loads them on demand via the select_tools tool. Only takes effect on models whose capability catalog declares dynamically loaded tools.', + env: TOOL_SELECT_FLAG_ENV, + default: false, + surface: 'core', +}; + +registerFlagDefinition(toolSelectFlag); diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelect.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelect.ts new file mode 100644 index 0000000000000000000000000000000000000000..1902ea8f0a40ed5751f0a85a6344e0aeab081413 --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelect.ts @@ -0,0 +1,35 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import type { ToolDescription as Tool } from '#human/llm/message'; +import type { ToolInfo } from '#/tool/toolContract'; + +export const SELECT_TOOLS_TOOL_NAME = 'select_tools'; + +export interface ShapedToolEntry extends ToolInfo { + readonly deferred?: true; +} + +export interface LoadToolsResult { + readonly toLoad: readonly string[]; + readonly alreadyAvailable: readonly string[]; + readonly unknown: readonly string[]; +} + +export interface IAgentToolSelectService { + readonly _serviceBrand: undefined; + + enabled(): boolean; + + shapeTools(entries: readonly ToolInfo[]): readonly ShapedToolEntry[]; + + shapeHistory(messages: readonly ContextMessage[]): readonly ContextMessage[]; + + load(names: readonly string[]): LoadToolsResult; + + drainPendingToolSchemas(): readonly Tool[] | undefined; + + loadableToolsAnnouncement(): string | undefined; +} + +export const IAgentToolSelectService: ServiceIdentifier = + createDecorator('agentToolSelectService'); diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncements.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncements.ts new file mode 100644 index 0000000000000000000000000000000000000000..fffa24b16929ab40780e68fe2610f31880fa4021 --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncements.ts @@ -0,0 +1,8 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IAgentToolSelectAnnouncementsService { + readonly _serviceBrand: undefined; +} + +export const IAgentToolSelectAnnouncementsService: ServiceIdentifier = + createDecorator('agentToolSelectAnnouncementsService'); diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts new file mode 100644 index 0000000000000000000000000000000000000000..257be673475b33be5418d42f02367432d1c235a1 --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts @@ -0,0 +1,32 @@ +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IAgentReminderService } from '#/features/reminder/reminderService'; + +import { LOADABLE_TOOLS_VARIANT } from './dynamicTools'; +import { IAgentToolSelectService } from './toolSelect'; +import { IAgentToolSelectAnnouncementsService } from './toolSelectAnnouncements'; + +export class AgentToolSelectAnnouncementsService extends Service implements IAgentToolSelectAnnouncementsService { + declare readonly _serviceBrand: undefined; + + constructor( + @IAgentToolSelectService toolSelect: IAgentToolSelectService, + @IAgentReminderService reminder: IAgentReminderService, + ) { + super(); + this._register( + reminder.register(LOADABLE_TOOLS_VARIANT, ({ isNewTurn }) => + isNewTurn ? toolSelect.loadableToolsAnnouncement() : undefined, + ), + ); + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentToolSelectAnnouncementsService, + AgentToolSelectAnnouncementsService, + ScopeActivation.OnScopeCreated, + 'toolSelect', +); diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemas.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemas.ts new file mode 100644 index 0000000000000000000000000000000000000000..c673e67c5af47f6cfd0176079e8d7c66d8d7f3d5 --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemas.ts @@ -0,0 +1,8 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IAgentToolSelectSchemasService { + readonly _serviceBrand: undefined; +} + +export const IAgentToolSelectSchemasService: ServiceIdentifier = + createDecorator('agentToolSelectSchemasService'); diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemasService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemasService.ts new file mode 100644 index 0000000000000000000000000000000000000000..f93fea4b91c8bfd7956fa3690d27b516c2809883 --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemasService.ts @@ -0,0 +1,34 @@ +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IAgentReminderService } from '#/features/reminder/reminderService'; + +import { DYNAMIC_TOOL_SCHEMA_VARIANT } from './dynamicTools'; +import { IAgentToolSelectService } from './toolSelect'; +import { IAgentToolSelectSchemasService } from './toolSelectSchemas'; + +export class AgentToolSelectSchemasService extends Service implements IAgentToolSelectSchemasService { + declare readonly _serviceBrand: undefined; + + constructor( + @IAgentToolSelectService toolSelect: IAgentToolSelectService, + @IAgentReminderService reminder: IAgentReminderService, + ) { + super(); + this._register( + reminder.register(DYNAMIC_TOOL_SCHEMA_VARIANT, () => { + const tools = toolSelect.drainPendingToolSchemas(); + if (tools === undefined) return undefined; + return { message: { role: 'system', content: [], tools } }; + }), + ); + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentToolSelectSchemasService, + AgentToolSelectSchemasService, + ScopeActivation.OnScopeCreated, + 'toolSelect', +); diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts new file mode 100644 index 0000000000000000000000000000000000000000..c8ab1d606365076d893cd2749184359811cd1574 --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts @@ -0,0 +1,330 @@ +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { defineState } from '#/state/state'; +import { IEventBus } from '#/app/event/eventBus'; +import { IFlagService } from '#/app/flag/flag'; +import type { ToolDescription as Tool } from '#human/llm/message'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { ContextSpliced } from '#/agent/contextMemory/contextEvents'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { CompactionCompleted } from '#/agent/fullCompaction/compactionOps'; +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'; +import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; + +import { + collectLoadedDynamicToolNames, + foldAnnouncedToolNames, + renderLoadableToolsAnnouncement, + stripDynamicToolContext, +} from './dynamicTools'; +import { TOOL_SELECT_FLAG_ID } from './flag'; +import { + IAgentToolSelectService, + SELECT_TOOLS_TOOL_NAME, + type LoadToolsResult, + type ShapedToolEntry, +} from './toolSelect'; + +export const toolSelectPendingLoadedKey = defineState>( + 'toolSelect.pendingLoaded', + () => new Set(), +); + +export class AgentToolSelectService extends Service implements IAgentToolSelectService { + declare readonly _serviceBrand: undefined; + + constructor( + @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, + @IAgentProfileService private readonly profile: IAgentProfileService, + @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, + @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, + @IFlagService private readonly flags: IFlagService, + @IEventBus eventBus: IEventBus, + @IAgentStateService private readonly states: IAgentStateService, + ) { + super(); + this.states.contributeState(toolSelectPendingLoadedKey); + this._register( + toolExecutor.registerUnavailableToolDescriber((name) => this.describeUnavailableTool(name)), + ); + this._register( + toolExecutor.registerMissingToolDescriber((name) => this.describeMissingTool(name)), + ); + this._register( + eventBus.subscribe(CompactionCompleted, () => { + this.pendingLoaded.clear(); + }), + ); + this._register( + eventBus.subscribe(ContextSpliced, (splice) => { + if (splice.deleteCount === 0 || splice.messages.length > 0) return; + this.dropPendingLoadedNotLanded(); + }), + ); + } + + private get pendingLoaded(): Set { + return this.states.get(toolSelectPendingLoadedKey); + } + + private dropPendingLoadedNotLanded(): void { + if (this.pendingLoaded.size === 0) return; + const landed = collectLoadedDynamicToolNames(this.context.get()); + for (const name of this.pendingLoaded) { + if (!landed.has(name)) this.pendingLoaded.delete(name); + } + } + + enabled(): boolean { + const capabilities = this.profile.getModelCapabilities(); + return ( + capabilities.dynamically_loaded_tools === true && + capabilities.tool_use && + this.flags.enabled(TOOL_SELECT_FLAG_ID) + ); + } + + shapeTools(entries: readonly ToolInfo[]): readonly ShapedToolEntry[] { + const disclosure = this.enabled(); + const activeEntries = this.activeEntries(entries, disclosure); + if (!disclosure) return activeEntries; + const loaded = this.loadedToolNames(); + const shaped: ShapedToolEntry[] = []; + for (const entry of activeEntries) { + if (entry.name === SELECT_TOOLS_TOOL_NAME) { + shaped.push(entry); + continue; + } + if (!this.isDynamicallyLoadable(entry)) { + shaped.push(entry); + continue; + } + if (!loaded.has(entry.name)) continue; + shaped.push({ ...entry, deferred: true }); + } + return shaped; + } + + shapeHistory(messages: readonly ContextMessage[]): readonly ContextMessage[] { + if (this.enabled()) return this.shapeActiveHistory(messages); + return stripDynamicToolContext(messages); + } + + load(names: readonly string[]): LoadToolsResult { + const loadable = new Set(this.loadableToolNames()); + const loaded = this.activeLoadedToolNames(); + const toLoad: string[] = []; + const alreadyAvailable: string[] = []; + const unknown: string[] = []; + for (const name of new Set(names)) { + if (loaded.has(name)) { + alreadyAvailable.push(name); + } else if (loadable.has(name)) { + toLoad.push(name); + } else { + unknown.push(name); + } + } + if (toLoad.length > 0) { + for (const name of toLoad) this.pendingLoaded.add(name); + } + return { toLoad, alreadyAvailable, unknown }; + } + + drainPendingToolSchemas(): readonly Tool[] | undefined { + if (!this.enabled() || this.pendingLoaded.size === 0) return undefined; + const names = [...this.pendingLoaded].toSorted((a, b) => a.localeCompare(b)); + const tools: Tool[] = []; + for (const name of names) { + const tool = this.schemaOf(name); + if (tool === undefined) continue; + this.pendingLoaded.delete(name); + tools.push(tool); + } + return tools.length === 0 ? undefined : tools; + } + + loadableToolsAnnouncement(): string | undefined { + if (!this.enabled()) return undefined; + const loadable = this.loadableToolNames(); + const loadableSet = new Set(loadable); + const announced = foldAnnouncedToolNames(this.context.get()); + const added = loadable.filter((name) => !announced.has(name)); + const removed = [...announced] + .filter((name) => !loadableSet.has(name)) + .toSorted((a, b) => a.localeCompare(b)); + if (added.length === 0 && removed.length === 0) return undefined; + return renderLoadableToolsAnnouncement(added, removed); + } + + private shouldIntercept(name: string): boolean { + if (!this.enabled()) return false; + const info = this.toolRegistry.list().find((entry) => entry.name === name); + if (info === undefined || !this.isDynamicallyLoadable(info)) return false; + if (!this.loadableToolNames().includes(name)) return false; + return !this.activeLoadedToolNames().has(name); + } + + private describeUnavailableTool(name: string): string | undefined { + if (this.isInactiveLoadedTool(name)) return inactiveLoadedToolOutput(name); + if (!this.shouldIntercept(name)) return undefined; + return notLoadedToolOutput(name); + } + + private describeMissingTool(name: string): string | undefined { + if (!this.enabled()) return undefined; + if (this.toolRegistry.resolve(name) !== undefined) return undefined; + if (!this.loadedToolNames().has(name)) return undefined; + if (isMcpToolName(name)) { + return ( + `Tool "${name}" was loaded but its MCP server is currently disconnected. ` + + 'It may become available again when the server reconnects; do not retry immediately.' + ); + } + return ( + `Tool "${name}" was loaded but is no longer registered. ` + + 'Do not retry it unless it becomes available again.' + ); + } + + private loadableToolNames(): string[] { + return this.toolRegistry + .list() + .filter( + (info) => + this.isDynamicallyLoadable(info) && + this.toolPolicy.isToolActive(info.name, info.source), + ) + .map((info) => info.name) + .toSorted((a, b) => a.localeCompare(b)); + } + + private loadedToolNames(): Set { + const names = collectLoadedDynamicToolNames(this.context.get()); + for (const name of this.pendingLoaded) names.add(name); + return names; + } + + private activeLoadedToolNames(): Set { + const names = this.loadedToolNames(); + for (const name of names) { + if (!this.isLoadedToolActive(name)) names.delete(name); + } + return names; + } + + private isInactiveLoadedTool(name: string): boolean { + if (!this.enabled()) return false; + return this.loadedToolNames().has(name) && !this.isLoadedToolActive(name); + } + + private isLoadedToolActive(name: string): boolean { + const info = this.toolRegistry.list().find((entry) => entry.name === name); + if (info !== undefined) { + return ( + this.isDynamicallyLoadable(info) && + this.toolPolicy.isToolActive(name, info.source) + ); + } + if (isMcpToolName(name)) return this.toolPolicy.isToolActive(name, 'mcp'); + return false; + } + + private isDynamicallyLoadable(info: ToolInfo): boolean { + return info.disclosure === 'deferred'; + } + + private shapeActiveHistory(messages: readonly ContextMessage[]): readonly ContextMessage[] { + let shaped: ContextMessage[] | undefined; + for (let i = 0; i < messages.length; i += 1) { + const message = messages[i]!; + const next = this.shapeActiveMessage(message); + if (next === message) { + if (shaped !== undefined) shaped.push(message); + continue; + } + if (shaped === undefined) shaped = messages.slice(0, i); + if (next !== undefined) shaped.push(next); + } + return shaped ?? messages; + } + + private shapeActiveMessage(message: ContextMessage): ContextMessage | undefined { + const tools = message.tools; + if (tools === undefined || tools.length === 0) return message; + + let kept: Tool[] | undefined; + for (let i = 0; i < tools.length; i += 1) { + const tool = tools[i]!; + if (this.isLoadedToolActive(tool.name)) { + if (kept !== undefined) kept.push(tool); + continue; + } + if (kept === undefined) kept = tools.slice(0, i); + } + if (kept === undefined) return message; + if (kept.length > 0) return { ...message, tools: kept }; + + const { tools: _tools, ...rest } = message; + void _tools; + if (rest.content.length === 0 && rest.toolCalls.length === 0) return undefined; + return rest; + } + + private schemaOf(name: string): Tool | undefined { + const tool = this.toolRegistry.resolve(name); + if (tool === undefined) return undefined; + return { + name: tool.name, + description: tool.description, + parameters: tool.parameters, + }; + } + + private activeEntries(entries: readonly ToolInfo[], disclosure: boolean): readonly ToolInfo[] { + let filtered: ToolInfo[] | undefined; + for (let i = 0; i < entries.length; i += 1) { + const entry = entries[i]!; + const active = + this.toolPolicy.isToolActive(entry.name, entry.source) || + (disclosure && + entry.name === SELECT_TOOLS_TOOL_NAME && + this.toolPolicy.isToolActiveForDisclosure(entry.name, entry.source)); + const keep = active && (disclosure || entry.name !== SELECT_TOOLS_TOOL_NAME); + if (keep) { + if (filtered !== undefined) filtered.push(entry); + continue; + } + if (filtered === undefined) filtered = entries.slice(0, i); + } + return filtered ?? entries; + } +} + +function notLoadedToolOutput(name: string): string { + return ( + `Tool "${name}" is available but not loaded. ` + + `Call select_tools with ["${name}"] first, then call the tool.` + ); +} + +function inactiveLoadedToolOutput(name: string): string { + return ( + `Tool "${name}" was loaded but is no longer active. ` + + 'Ask the user to enable it before calling it again.' + ); +} + +registerScopedService( + LifecycleScope.Agent, + IAgentToolSelectService, + AgentToolSelectService, + ScopeActivation.OnScopeCreated, + 'toolSelect', +); diff --git a/packages/agent-core-v2/src/agent/tools/agent/agent-background-disabled.md b/packages/agent-core-v2/src/agent/tools/agent/agent-background-disabled.md new file mode 100644 index 0000000000000000000000000000000000000000..7816e29ad534aec788270889799ad8fe0d96a086 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/agent/agent-background-disabled.md @@ -0,0 +1 @@ +Background agent execution is disabled for this agent. Do not set `run_in_background=true` — any call that sets it is rejected before the subagent launches. Run every subagent in the foreground and wait for its result. \ No newline at end of file diff --git a/packages/agent-core-v2/src/agent/tools/agent/agent-background-enabled.md b/packages/agent-core-v2/src/agent/tools/agent/agent-background-enabled.md new file mode 100644 index 0000000000000000000000000000000000000000..7c2c65f35754cc22e720d9fe1317d28e2b9b5603 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/agent/agent-background-enabled.md @@ -0,0 +1,3 @@ +When `run_in_background=true`, the subagent runs detached from this turn. The completion arrives in a later turn as a synthetic user-role message containing its result — you do not need to poll, sleep, or check on its progress. Continue with other work or respond to the user. Never fabricate or predict what the result will say. + +Default to a foreground subagent (omit `run_in_background`) when your next step needs its result — foreground hands the result straight back. Reach for `run_in_background=true` only when you have other work to do while it runs and do not need its result to proceed. Never launch in the background and then immediately wait on it (by polling `TaskOutput`, sleeping, or otherwise): that just blocks the turn for no benefit — run it in the foreground instead. diff --git a/packages/agent-core-v2/src/agent/tools/agent/agent-fork.md b/packages/agent-core-v2/src/agent/tools/agent/agent-fork.md new file mode 100644 index 0000000000000000000000000000000000000000..e2aa82ee1882486aa917fb1d97b4807f25ec433e --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/agent/agent-fork.md @@ -0,0 +1 @@ +Context forking: when the task builds on this conversation, pass `fork: true` instead of briefing from scratch — the subagent then starts with a snapshot of your completed history (inheriting your own agent type, tool set, and model), so the prompt only needs the task itself. A non-empty `resume` is rejected with `fork`; `subagent_type` must match your own agent type; `model` must be your own model or `primary`. \ No newline at end of file diff --git a/packages/agent-core-v2/src/agent/tools/agent/agent.md b/packages/agent-core-v2/src/agent/tools/agent/agent.md new file mode 100644 index 0000000000000000000000000000000000000000..ff6d1de8c657617f05470863e7e02172ad8b187a --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/agent/agent.md @@ -0,0 +1,15 @@ +Launch a subagent to handle a task. The subagent runs as a same-process loop instance with its own context and wire file. Delegating also keeps the bulk of intermediate file contents out of your own context — you get a conclusion back instead of a pile of dumps. + +Writing the prompt: +- The subagent starts with zero context — it has not seen this conversation. Brief it like a colleague who just walked into the room: state the goal, list what you already know, hand over the specifics. +- Lookups (read this file, run that test): put the exact path or command in the prompt. The subagent should not have to search for things you already know. +- Investigations (figure out X, find why Y): give the question, not prescribed steps — fixed steps become dead weight when the premise is wrong. +- Do not delegate understanding. If the task hinges on a file path or line number, find it yourself first and write it into the prompt. + +Usage notes: +- When the task continues earlier work a subagent already did, prefer resuming that agent (pass its `resume` id) over spawning a fresh instance — the resumed agent keeps its prior context. +- A subagent's result is only visible to you, not to the user. When the user needs to see what a subagent produced, summarize the relevant parts yourself in your own reply. + +When NOT to use Agent: skip delegation for trivial work you can do directly — reading a file whose path you already know, searching a small known set of files, or any task that takes only a step or two. Delegation has a context-handoff cost; it pays off only when the task is substantial enough to outweigh it. + +Once a subagent is running, leave that scope to it: do not redo its searches or reads in parallel, and do not abandon it midway and finish the job manually. Both undo the context savings the delegation was meant to buy. diff --git a/packages/agent-core-v2/src/agent/tools/agent/agent.ts b/packages/agent-core-v2/src/agent/tools/agent/agent.ts new file mode 100644 index 0000000000000000000000000000000000000000..77da6c2aed1ce1e1b75e9156f5a9d36315527f84 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/agent/agent.ts @@ -0,0 +1,93 @@ +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; +import { DEFAULT_PROFILE_NAME } from '#/session/subagent/spawn'; + +export { DEFAULT_PROFILE_NAME }; +export const RESUMED_LABEL = 'subagent'; + +export const SubagentToolInputSchema = z.preprocess( + (input) => { + if (typeof input !== 'object' || input === null || Array.isArray(input)) { + return input; + } + const record = input as Record; + const normalized = { ...record }; + const hasResumeId = + typeof normalized['resume'] === 'string' && normalized['resume'].trim().length > 0; + const hasSubagentType = + typeof normalized['subagent_type'] === 'string' && normalized['subagent_type'].length > 0; + const hasFork = normalized['fork'] === true; + if (!hasSubagentType && !hasResumeId && !hasFork) { + normalized['subagent_type'] = DEFAULT_PROFILE_NAME; + } else if (!hasSubagentType) { + delete normalized['subagent_type']; + } + return normalized; + }, + z.object({ + prompt: z.string().describe('Full task prompt for the subagent'), + description: z.string().describe('Short task description (3-5 words) for UI display'), + subagent_type: z + .string() + .optional() + .describe( + 'One of the available agent types (see "Available agent types" in this tool description). Defaults to "coder" when omitted.', + ), + resume: z + .string() + .optional() + .describe( + 'Optional agent ID to resume instead of creating a new instance. When set, do not also pass subagent_type — the resumed agent keeps its own type, and supplying both is rejected.', + ), + run_in_background: z + .boolean() + .optional() + .describe( + 'If true, return immediately without waiting for completion. Prefer false unless the task can run independently and there is a clear benefit to not waiting.', + ), + fork: z + .boolean() + .optional() + .describe( + 'Fork the current context: the subagent starts with a snapshot of this agent\'s completed conversation history instead of zero context, inheriting this agent\'s agent type, tool set, and model. A non-empty resume is rejected. If subagent_type is provided, it must match this agent\'s type; if model is provided, it must be this agent\'s model or "primary". Different types and model overrides are rejected.', + ), + model: z + .string() + .optional() + .describe( + 'Which model to run the subagent on: one of the aliases listed under "Available models" in this tool description, or "primary" for your current model and thinking level. When omitted, the configured default model is used. Ignored when resuming — resumed subagents keep their own model.', + ), + }), +); + +export type SubagentToolInput = z.infer; + +export const SubagentToolOutputSchema = z.object({ + result: z.string().describe('Aggregated text output from the subagent'), + usage: z + .object({ + input: z.number().int().nonnegative(), + output: z.number().int().nonnegative(), + cache_read: z.number().int().nonnegative().optional(), + cache_write: z.number().int().nonnegative().optional(), + }) + .describe('Cumulative token usage'), +}); + +export type SubagentToolOutput = z.infer; + +export const BACKGROUND_AGENT_UNAVAILABLE = + 'Background agent execution is not available for this agent because TaskList, TaskOutput, and TaskStop are not enabled.'; +export const RESUME_WITH_TYPE_UNAVAILABLE = + 'Cannot set subagent_type when resuming an existing agent. Resume by agent id only.'; +export const USER_INTERRUPTED_SUBAGENT_MESSAGE = + 'The subagent was stopped before it finished by user.'; +export const SUBAGENT_STOPPED_MESSAGE = 'The subagent was stopped before it finished.'; + +export interface ISubagentTool extends AgentTool { + readonly _serviceBrand: undefined; +} + +export const ISubagentTool = createDecorator('subagentTool'); diff --git a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts new file mode 100644 index 0000000000000000000000000000000000000000..69ef06fb884354b8a9f5d60e804bc5eb651f7ccc --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -0,0 +1,766 @@ +import { type CollectionView } from '#/_base/di/collection'; +import type { IAgentScopeHandle } from '#/_base/di/scope'; +import { + isAbortError, + isUserCancellation, + userCancellationReason, +} from '#/_base/utils/abort'; +import { Error2, ErrorCodes, isError2 } from '#/errors'; +import { REPEAT_BREAKER_STOP_REASON } from '#/agent/toolDedupe/toolDedupe'; +import type { AgentTaskInfo } from '#/agent/task/types'; +import { toInputJsonSchema } from '#/tool/input-schema'; +import { matchesGlobRuleSubject } from '#/tool/rule-match'; +import { + IAgentTaskService, + type RegisterAgentTaskOptions, +} from '#/agent/task/task'; +import { IAgentProfileService } from '#/agent/profile/profile'; +import { + isToolActive as evaluateToolActive, + resolveActiveToolNames, +} from '#/agent/toolPolicy/evaluate'; +import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentLoopService } from '#/agent/loop/loop'; +import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; +import { + ToolAccesses, + type ExecutableToolContext, + type ExecutableToolResult, + type ToolExecution, +} from '#/tool/toolContract'; +import { + AgentToolContribution, + registerAgentToolService, +} from '#/agent/toolRegistry/toolContribution'; +import { IAgentToolRegistryService, type ToolReference } from '#/agent/toolRegistry/toolRegistry'; +import { type AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; +import { + rootDelegationExtras, + subagentAllowlistFor, + withoutDelegatingTargets, +} from '#/app/agentProfileCatalog/profile-shared'; +import { ILogService } from '#/_base/log/log'; +import { hasPinnedPermissionMode } from '#/features/tower/tower'; +import { IConfigService } from '#/app/config/config'; +import { IFlagService } from '#/app/flag/flag'; +import { ISessionNotify } from '#/features/notify/sessionNotify'; +import { NOTIFY_USER_TOOL_NAME } from '#/features/notify/tools/notify-user/notify-user'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import { createAgentAwaitingClose } from '#/session/agentLifecycle/createAwaitingClose'; +import { + isSubagentMeta, + labelsFromAgentMeta, + subagentLabels, + subagentParentAgentId, + subagentProfileName, +} from '#/session/agentLifecycle/subagentMetadata'; +import { type AgentMeta, ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; + +import { emitAgentRunSpawned, mirrorAgentRun, SubagentStarted } from '#/session/subagent/mirrorAgentRun'; +import { IEventDispatcher } from '#/state/eventDispatcher'; +import { ISessionSubagentService } from '#/session/subagent/subagent'; +import { FORK_EXPERIMENTAL_UNAVAILABLE, forkIncompatibility } from '#/session/subagent/spawn'; +import { SUBAGENT_FORK_FLAG_ID } from '#/session/subagent/flag'; +import { + buildSubagentModelDescriptions, + exposesSubagentModelChoice, + formatSubagentTimeoutDescription, + resolveSubagentTimeoutMs, + stripSubagentForkParameter, + stripSubagentModelParameter, + type SubagentModelSource, +} from '#/session/subagent/configSection'; +import { + BACKGROUND_AGENT_UNAVAILABLE, + DEFAULT_PROFILE_NAME, + ISubagentTool, + RESUME_WITH_TYPE_UNAVAILABLE, + RESUMED_LABEL, + SUBAGENT_STOPPED_MESSAGE, + SubagentToolInputSchema, + USER_INTERRUPTED_SUBAGENT_MESSAGE, + type SubagentToolInput, +} from './agent'; +import { SubagentTask, type SubagentHandle } from './subagent-task'; + +import AGENT_BACKGROUND_DISABLED_DESCRIPTION from './agent-background-disabled.md?raw'; +import AGENT_BACKGROUND_DESCRIPTION from './agent-background-enabled.md?raw'; +import AGENT_DESCRIPTION_BASE from './agent.md?raw'; +import AGENT_FORK_DESCRIPTION from './agent-fork.md?raw'; + +const SUBAGENT_TOOL_PARAMETERS = toInputJsonSchema(SubagentToolInputSchema); +const SUBAGENT_TOOL_PARAMETERS_NO_MODEL = stripSubagentModelParameter(SUBAGENT_TOOL_PARAMETERS); + +export class SubagentTool implements ISubagentTool { + declare readonly _serviceBrand: undefined; + readonly name: string = 'Agent'; + + get parameters(): Record { + const parameters = exposesSubagentModelChoice(this.config) + ? SUBAGENT_TOOL_PARAMETERS + : SUBAGENT_TOOL_PARAMETERS_NO_MODEL; + return this.flags.enabled(SUBAGENT_FORK_FLAG_ID) + ? parameters + : stripSubagentForkParameter(parameters); + } + + private readonly callerAgentId: string; + private readonly canRunInBackground: () => boolean; + private catalogReady = false; + private frozenCatalogProfiles: readonly AgentProfile[] | undefined; + + constructor( + @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, + @ISessionSubagentService private readonly subagents: ISessionSubagentService, + @ISessionAgentProfileCatalog private readonly catalog: ISessionAgentProfileCatalog, + @IAgentScopeContext scopeContext: IAgentScopeContext, + @IAgentTaskService private readonly tasks: IAgentTaskService, + @IAgentProfileService private readonly profile: IAgentProfileService, + @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, + @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, + @IAgentPermissionModeService private readonly permissionMode: IAgentPermissionModeService, + @ISessionMetadata private readonly sessionMetadata: ISessionMetadata, + @ILogService private readonly log: ILogService, + @IConfigService private readonly config: IConfigService, + @IFlagService private readonly flags: IFlagService, + @ISessionNotify private readonly notify: ISessionNotify, + @AgentToolContribution private readonly contributions: CollectionView, + ) { + this.callerAgentId = scopeContext.agentId; + this.canRunInBackground = () => + this.toolPolicy.isToolActive('TaskList') && + this.toolPolicy.isToolActive('TaskOutput') && + this.toolPolicy.isToolActive('TaskStop'); + void this.catalog.ready.then(() => { + this.catalogReady = true; + }); + } + + get description(): string { + const backgroundDescription = this.canRunInBackground() + ? AGENT_BACKGROUND_DESCRIPTION + : AGENT_BACKGROUND_DISABLED_DESCRIPTION; + let description = `${AGENT_DESCRIPTION_BASE}\n\n${backgroundDescription}`; + if (this.flags.enabled(SUBAGENT_FORK_FLAG_ID)) { + description += `\n\n${AGENT_FORK_DESCRIPTION}`; + } + const own = this.profile.data(); + const catalogProfiles = this.catalogProfiles(); + const allowlist = this.effectiveAllowlist(own, catalogProfiles); + const profiles = + allowlist === undefined + ? catalogProfiles + : catalogProfiles.filter((profile) => allowlist.includes(profile.name)); + const notifyAvailable = this.notify.enabled; + const typeLines = buildProfileDescriptions( + profiles.map((profile) => ({ + ...profile, + tools: profile.tools?.filter((name) => name !== NOTIFY_USER_TOOL_NAME || notifyAvailable), + })), + this.knownToolReferences(), + (profile, name, source) => + this.toolPolicy.isToolActiveForProfile(profile, name, source), + ); + if (typeLines) { + description += `\n\nAvailable agent types (pass via subagent_type):\n${typeLines}`; + } + const modelLines = buildSubagentModelDescriptions( + this.config, + this.profile.data().modelAlias, + ); + if (modelLines !== undefined) { + description += `\n\n${modelLines}`; + } + return description; + } + + private catalogProfiles(): readonly AgentProfile[] { + if (this.frozenCatalogProfiles !== undefined) return this.frozenCatalogProfiles; + const profiles = this.catalog.list(); + if (this.catalogReady) this.frozenCatalogProfiles = profiles; + return profiles; + } + + private delegationExtras( + own: { + readonly profileName?: string; + readonly subagents?: readonly string[]; + }, + profiles: readonly AgentProfile[], + ): readonly string[] | undefined { + if (this.callerAgentId !== 'main') return undefined; + return rootDelegationExtras(this.catalog, own, profiles); + } + + private effectiveAllowlist( + own: { + readonly profileName?: string; + readonly subagents?: readonly string[]; + }, + profiles: readonly AgentProfile[], + ): readonly string[] | undefined { + const allowlist = subagentAllowlistFor( + this.catalog, + own, + this.delegationExtras(own, profiles), + ); + if (allowlist === undefined || own.subagents !== undefined) return allowlist; + return withoutDelegatingTargets(this.catalog, allowlist); + } + + private knownToolReferences(): ToolReference[] { + const refs = new Map(); + for (const contribution of this.contributions.items) { + refs.set(contribution.options.name, { + name: contribution.options.name, + source: contribution.options.source ?? 'builtin', + }); + } + for (const ref of this.toolRegistry.listReferences()) { + if (!refs.has(ref.name)) refs.set(ref.name, ref); + } + return [...refs.values()]; + } + + async resolveExecution(args: SubagentToolInput): Promise { + const requestedProfileName = args.subagent_type?.length ? args.subagent_type : undefined; + const resumeAgentId = args.resume?.trim(); + + if ( + resumeAgentId !== undefined && + resumeAgentId.length > 0 && + requestedProfileName !== undefined + ) { + return { output: RESUME_WITH_TYPE_UNAVAILABLE, isError: true }; + } + + if (args.fork === true) { + if (!this.flags.enabled(SUBAGENT_FORK_FLAG_ID)) { + return { output: FORK_EXPERIMENTAL_UNAVAILABLE, isError: true }; + } + const forkError = forkIncompatibility(args, this.profile.data()); + if (forkError !== undefined) { + return { output: forkError, isError: true }; + } + } + + const profileNameForDisplay = + resumeAgentId !== undefined && resumeAgentId.length > 0 + ? (await this.resumeProfileName(resumeAgentId)) ?? RESUMED_LABEL + : (requestedProfileName ?? + (args.fork === true + ? (this.profile.data().profileName ?? DEFAULT_PROFILE_NAME) + : DEFAULT_PROFILE_NAME)); + const prefix = args.run_in_background === true ? 'Launching background' : 'Launching'; + return { + description: `${prefix} ${profileNameForDisplay} agent: ${args.description}`, + accesses: ToolAccesses.none(), + display: { + kind: 'agent_call', + agent_name: profileNameForDisplay, + prompt: args.prompt, + background: args.run_in_background, + }, + approvalRule: this.name, + matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, profileNameForDisplay), + execute: (ctx) => this.execution(args, ctx), + }; + } + + private async resumeProfileName(agentId: string): Promise { + const target = this.agentLifecycle.handleOf(agentId); + if (target !== undefined) return target.accessor.get(IAgentProfileService).data().profileName; + return subagentProfileName((await this.sessionMetadata.read()).agents?.[agentId]); + } + + private async launch( + args: SubagentToolInput, + toolCallId: string, + controller: AbortController, + ): Promise { + const requester = this.agentLifecycle.handleOf(this.callerAgentId); + if (requester === undefined) { + throw new Error2( + ErrorCodes.AGENT_NOT_FOUND, + `Caller agent "${this.callerAgentId}" does not exist`, + { details: { agentId: this.callerAgentId } }, + ); + } + + const resumeAgentId = args.resume?.trim(); + const isResume = resumeAgentId !== undefined && resumeAgentId.length > 0; + + let agentId: string; + let profileName: string; + let displayModel: string | undefined; + let displayModelSource: SubagentModelSource | undefined; + let promptText = args.prompt; + if (isResume) { + const target = await this.resolveResumeTarget(resumeAgentId, controller.signal); + agentId = target.id; + const resumed = target.accessor.get(IAgentProfileService).data(); + profileName = resumed.profileName ?? RESUMED_LABEL; + displayModel = resumed.modelAlias; + } else { + const plan = await this.subagents.planSpawn({ + callerAgentId: this.callerAgentId, + profileName: args.subagent_type, + model: args.model, + fork: args.fork === true, + }); + const spawned = await this.subagents.spawn({ + callerAgentId: this.callerAgentId, + plan, + labels: subagentLabels(this.callerAgentId), + prompt: args.prompt, + }); + agentId = spawned.agentId; + profileName = spawned.profileName; + displayModel = spawned.model; + displayModelSource = spawned.modelSource; + promptText = spawned.promptText; + } + + const target = this.agentLifecycle.handleOf(agentId); + if (target === undefined) throw new Error(`Agent "${agentId}" does not exist`); + const run = await this.subagents.run( + target.accessor.get(IAgentScopeContext).agentContext, + { kind: 'prompt', prompt: promptText }, + { signal: controller.signal }, + ); + const mirrored = mirrorAgentRun(requester, run, { + profileName, + prompt: promptText, + signal: controller.signal, + deferStarted: true, + cancel: (reason) => { + controller.abort(reason); + }, + }); + return { + agentId, + profileName, + parentToolCallId: toolCallId, + model: displayModel, + modelSource: displayModelSource, + thinkingEffort: this.agentLifecycle.handleOf(agentId) + ?.accessor.get(IAgentProfileService) + .getEffectiveThinkingLevel(), + completion: mirrored.then((r) => ({ + result: r.summary, + usage: r.usage, + stopReason: r.stopReason, + })), + }; + } + + private async resolveResumeTarget( + agentId: string, + signal: AbortSignal, + ): Promise { + const meta = (await this.sessionMetadata.read()).agents?.[agentId]; + const live = this.agentLifecycle.handleOf(agentId); + if (meta === undefined && live === undefined) { + throw new Error2(ErrorCodes.AGENT_NOT_FOUND, `Agent instance "${agentId}" does not exist`, { + details: { agentId }, + }); + } + if (meta === undefined || !isSubagentMeta(meta)) { + throw new Error2(ErrorCodes.AGENT_NOT_A_SUBAGENT, `Agent instance "${agentId}" is not a subagent`, { + details: { agentId }, + }); + } + if (subagentParentAgentId(meta) !== this.callerAgentId) { + throw new Error2( + ErrorCodes.AGENT_NOT_OWNED, + `Agent instance "${agentId}" does not belong to this parent agent`, + { details: { agentId, callerAgentId: this.callerAgentId } }, + ); + } + const target = live ?? (await this.rebuildSubagent(agentId, meta, signal)); + if (target.accessor.get(IAgentLoopService).snapshot().state === 'running') { + throw new Error2( + ErrorCodes.AGENT_ALREADY_RUNNING, + `Agent instance "${agentId}" is already running and cannot run concurrently`, + { details: { agentId } }, + ); + } + return target; + } + + private async rebuildSubagent( + agentId: string, + meta: AgentMeta, + signal: AbortSignal, + ): Promise { + await createAgentAwaitingClose( + this.agentLifecycle, + { agentId, labels: labelsFromAgentMeta(meta), forkedFrom: meta.forkedFrom }, + signal, + ); + const rebuilt = this.agentLifecycle.handleOf(agentId); + if (rebuilt === undefined) { + throw new Error2(ErrorCodes.AGENT_NOT_FOUND, `Agent instance "${agentId}" does not exist`, { + details: { agentId }, + }); + } + if (!hasPinnedPermissionMode(rebuilt.accessor.get(IAgentProfileService).data().profileName)) { + rebuilt.accessor.get(IAgentPermissionModeService).setMode(this.permissionMode.mode); + } + this.log.info('subagent rebuilt for resume', { agentId, callerAgentId: this.callerAgentId }); + return rebuilt; + } + + private async execution( + args: SubagentToolInput, + { toolCallId, signal }: ExecutableToolContext, + ): Promise { + try { + signal.throwIfAborted(); + const runInBackground = args.run_in_background === true; + const requestedProfileName = args.subagent_type?.length ? args.subagent_type : undefined; + const resumeAgentId = args.resume?.trim(); + const isResume = resumeAgentId !== undefined && resumeAgentId.length > 0; + + if (isResume && requestedProfileName !== undefined) { + return { output: RESUME_WITH_TYPE_UNAVAILABLE, isError: true }; + } + + if (args.fork === true) { + if (!this.flags.enabled(SUBAGENT_FORK_FLAG_ID)) { + return { output: FORK_EXPERIMENTAL_UNAVAILABLE, isError: true }; + } + const forkError = forkIncompatibility(args, this.profile.data()); + if (forkError !== undefined) { + return { output: forkError, isError: true }; + } + } + + const allowBackground = this.canRunInBackground(); + if (runInBackground && !allowBackground) { + return { output: BACKGROUND_AGENT_UNAVAILABLE, isError: true }; + } + const timeoutMs = resolveSubagentTimeoutMs(this.config); + + const controller = new AbortController(); + const abortBeforeRegister = (): void => { + controller.abort(signal.reason); + }; + if (!runInBackground) { + signal.addEventListener('abort', abortBeforeRegister, { once: true }); + } + + let handle: SubagentHandle; + try { + handle = await this.launch(args, toolCallId, controller); + } catch (error) { + signal.removeEventListener('abort', abortBeforeRegister); + this.log.warn('subagent launch failed', { + toolCallId, + runInBackground, + operation: isResume ? 'resume' : 'spawn', + subagentType: requestedProfileName ?? DEFAULT_PROFILE_NAME, + resumeAgentId: isResume ? resumeAgentId : undefined, + error, + }); + throw error; + } + + let taskId: string; + try { + const registerOptions: RegisterAgentTaskOptions = { + detached: runInBackground, + timeoutMs, + signal: runInBackground ? undefined : signal, + }; + taskId = this.tasks.registerTask( + new SubagentTask(handle, args.description, controller), + registerOptions, + ); + signal.removeEventListener('abort', abortBeforeRegister); + } catch (error) { + controller.abort(); + void handle.completion.catch(() => {}); + signal.removeEventListener('abort', abortBeforeRegister); + this.log?.warn('background agent task registration failed', { + toolCallId, + agentId: handle.agentId, + subagentType: handle.profileName, + error, + }); + const message = error instanceof Error ? error.message : String(error); + return { + output: + isError2(error) && error.code === ErrorCodes.TASK_LIMIT_EXCEEDED + ? 'Too many background tasks are already running.' + : message, + isError: true, + }; + } + + const requester = this.agentLifecycle.handleOf(this.callerAgentId); + if (requester !== undefined) { + emitAgentRunSpawned(requester, handle.agentId, { + profileName: handle.profileName, + parentToolCallId: toolCallId, + description: args.description, + runInBackground, + fork: args.fork === true, + model: handle.model, + modelSource: handle.modelSource, + taskId, + }); + void requester.accessor + .get(IEventDispatcher) + ?.dispatch(new SubagentStarted({ subagentId: handle.agentId })); + } + + if (runInBackground) { + return { + output: formatBackgroundAgentResult(taskId, handle, args.description, allowBackground, false), + }; + } + + const release = await this.tasks.waitForForegroundRelease(taskId); + if (release === 'detached') { + return { + output: formatBackgroundAgentResult(taskId, handle, args.description, allowBackground, true), + }; + } + return await this.formatForegroundResult(taskId, handle, timeoutMs); + } catch (error) { + return { output: `subagent error: ${launchErrorMessage(error, signal)}`, isError: true }; + } + } + + private async formatForegroundResult( + taskId: string, + handle: SubagentHandle, + timeoutMs: number, + ): Promise { + const info = this.tasks.getTask(taskId); + const stopCode = info?.kind === 'agent' ? info.stopCode : undefined; + if (info?.status === 'completed') { + return { + output: formatForegroundAgentSuccess(handle, await this.tasks.readOutput(taskId), stopCode), + }; + } + const timedOut = info?.status === 'timed_out'; + const message = timedOut + ? `Agent timed out after ${formatSubagentTimeoutDescription(timeoutMs)}.` + : formatSubagentStoppedMessage(info?.stopReason); + return { + output: formatForegroundAgentFailure(handle, message, failureStopReason(info, stopCode)), + isError: true, + }; + } +} + +type SubagentStopReason = + | 'completed' + | 'repeat_breaker' + | 'max_tokens' + | 'max_steps' + | 'filtered' + | 'provider_error' + | 'no_final_message' + | 'cancelled' + | 'stopped' + | 'timed_out' + | 'error'; + +const REASON_MAX_CHARS = 2000; + +const REPEAT_BREAKER_NOTICE = + 'notice: The subagent was stopped by the repeat breaker after issuing the same tool call repeatedly. The summary below is its handoff, not a finished result.'; + +function resumeHint(agentId: string, prompt: string): string { + return `resume_hint: Continue with Agent(resume="${agentId}", prompt="${prompt}"). Use agent_id only; do not set subagent_type. The subagent retains its prior context; redo any unfinished tool call if its result was lost.`; +} + +const RESUME_NEXT_STEP = + 'next_step: Resume to continue where it stopped, or take over the task yourself; if neither works, report the failure to the user.'; + +const NEXT_STEP_BY_REASON: Readonly> = { + completed: undefined, + repeat_breaker: + 'next_step: The subagent was stuck on one tool call. If you resume it, change the instructions or supply the missing input; otherwise continue the work yourself.', + cancelled: 'next_step: The user stopped this subagent. Do not restart it unless the user asks.', + filtered: + 'next_step: Resuming is unlikely to help; rephrase or split the task before trying again.', + max_tokens: RESUME_NEXT_STEP, + max_steps: RESUME_NEXT_STEP, + provider_error: RESUME_NEXT_STEP, + no_final_message: RESUME_NEXT_STEP, + stopped: RESUME_NEXT_STEP, + timed_out: RESUME_NEXT_STEP, + error: RESUME_NEXT_STEP, +}; + +const STOP_REASON_BY_CODE: Readonly> = { + [REPEAT_BREAKER_STOP_REASON]: 'repeat_breaker', + [ErrorCodes.AGENT_MAX_TOKENS_EXCEEDED]: 'max_tokens', + [ErrorCodes.LOOP_MAX_STEPS_EXCEEDED]: 'max_steps', + [ErrorCodes.PROVIDER_FILTERED]: 'filtered', + [ErrorCodes.PROVIDER_RATE_LIMIT]: 'provider_error', + [ErrorCodes.PROVIDER_API_ERROR]: 'provider_error', + [ErrorCodes.PROVIDER_OVERLOADED]: 'provider_error', + [ErrorCodes.PROVIDER_CONNECTION_ERROR]: 'provider_error', + [ErrorCodes.PROVIDER_AUTH_ERROR]: 'provider_error', + [ErrorCodes.AGENT_NO_FINAL_MESSAGE]: 'no_final_message', +}; + +function nextStep(reason: SubagentStopReason): string | undefined { + return NEXT_STEP_BY_REASON[reason]; +} + +function failureStopReason( + info: AgentTaskInfo | undefined, + stopCode: string | undefined, +): SubagentStopReason { + if (info?.status === 'timed_out') return 'timed_out'; + if (info?.status === 'killed') { + return info.stopReason?.trim() === userCancellationReason().message ? 'cancelled' : 'stopped'; + } + if (stopCode === undefined) return 'error'; + return STOP_REASON_BY_CODE[stopCode] ?? 'error'; +} + +function truncateReason(reason: string): string { + if (reason.length <= REASON_MAX_CHARS) return reason; + return `${reason.slice(0, REASON_MAX_CHARS)}… [truncated]`; +} + +registerAgentToolService(ISubagentTool, SubagentTool, { + name: 'Agent', + domain: 'subagent', + requiredRuntimeCapabilities: ['process'], +}); + +function buildProfileDescriptions( + profiles: readonly AgentProfile[], + tools: readonly ToolReference[], + isToolActive: ( + profile: { readonly tools?: readonly string[]; readonly disallowedTools?: readonly string[] }, + name: string, + source: ToolReference['source'], + ) => boolean, +): string { + return profiles + .map((profile) => { + const details = [profile.description, profile.whenToUse].filter( + (part): part is string => part !== undefined && part.length > 0, + ); + const header = details.length === 0 ? `- ${profile.name}` : `- ${profile.name}: ${details.join(' ')}`; + const activeTools = resolveActiveToolNames(profile); + const externallyRestricted = tools.some( + (tool) => + evaluateToolActive(profile, tool.name, tool.source) && + !isToolActive(profile, tool.name, tool.source), + ); + if (externallyRestricted) { + const effectiveTools = tools + .filter((tool) => isToolActive(profile, tool.name, tool.source)) + .map((tool) => tool.name); + if (effectiveTools.length === 0) { + return `${header}\n Tools: none`; + } + return `${header}\n Tools: ${effectiveTools.join(', ')}`; + } + if (activeTools === undefined) { + if ((profile.disallowedTools?.length ?? 0) > 0) { + return `${header}\n Tools: all except ${profile.disallowedTools!.join(', ')}`; + } + return `${header}\n Tools: all`; + } + if (activeTools.length === 0) { + return `${header}\n Tools: none`; + } + return `${header}\n Tools: ${activeTools.join(', ')}`; + }) + .join('\n'); +} + +function formatBackgroundAgentResult( + taskId: string, + handle: SubagentHandle, + description: string, + allowBackground: boolean, + detachedByUser: boolean, +): string { + const nextStep = allowBackground + ? `next_step: The completion arrives automatically in a later turn — do NOT wait, poll, or call TaskOutput on it; continue with other work or hand back to the user. (If you have nothing to do until it finishes, run such tasks in the foreground next time.)` + : 'next_step: The completion arrives automatically in a later turn.'; + return [ + `task_id: ${taskId}`, + 'status: running', + `agent_id: ${handle.agentId}`, + `actual_subagent_type: ${handle.profileName}`, + 'automatic_notification: true', + '', + `description: ${description}`, + '', + detachedByUser ? `note: The user moved this subagent to the background.\n${nextStep}` : nextStep, + `resume_hint: To continue or recover this same subagent later, call Agent(resume="${handle.agentId}", prompt="..."). The parameter is agent_id ("${handle.agentId}"), NOT task_id ("${taskId}") or source_id from a later . Recovery cases: a later for this subagent — its conversation history is preserved across session restarts and resume will pick it up.`, + ].join('\n'); +} + +function formatForegroundAgentSuccess( + handle: SubagentHandle, + result: string, + stopCode: string | undefined, +): string { + const reason: SubagentStopReason = + stopCode === REPEAT_BREAKER_STOP_REASON ? 'repeat_breaker' : 'completed'; + const lines = [ + `agent_id: ${handle.agentId}`, + `actual_subagent_type: ${handle.profileName}`, + 'status: completed', + `stop_reason: ${reason}`, + ]; + if (reason === 'repeat_breaker') lines.push(REPEAT_BREAKER_NOTICE); + lines.push('', '[summary]', result, '', resumeHint(handle.agentId, '...')); + const next = nextStep(reason); + if (next !== undefined) lines.push(next); + return lines.join('\n'); +} + +function formatForegroundAgentFailure( + handle: SubagentHandle, + message: string, + reason: SubagentStopReason, +): string { + const lines = [ + `agent_id: ${handle.agentId}`, + `actual_subagent_type: ${handle.profileName}`, + 'status: failed', + `stop_reason: ${reason}`, + '', + `subagent error: ${message}`, + ]; + if (reason !== 'cancelled') lines.push(resumeHint(handle.agentId, 'continue')); + const next = nextStep(reason); + if (next !== undefined) lines.push(next); + return lines.join('\n'); +} + +function launchErrorMessage(error: unknown, signal: AbortSignal): string { + if (isUserCancellation(signal.reason)) return USER_INTERRUPTED_SUBAGENT_MESSAGE; + if (isAbortError(error)) return formatSubagentStoppedMessage(errorMessage(signal.reason)); + return error instanceof Error ? error.message : String(error); +} + +function formatSubagentStoppedMessage(reason: string | undefined): string { + const normalized = reason?.trim(); + if (normalized === userCancellationReason().message) return USER_INTERRUPTED_SUBAGENT_MESSAGE; + if (normalized === undefined || normalized.length === 0) return SUBAGENT_STOPPED_MESSAGE; + return `${SUBAGENT_STOPPED_MESSAGE} Reason: ${truncateReason(normalized)}`; +} + +function errorMessage(error: unknown): string | undefined { + if (typeof error === 'string') return error; + if (error instanceof Error) return error.message; + return undefined; +} diff --git a/packages/agent-core-v2/src/agent/tools/agent/subagent-task.ts b/packages/agent-core-v2/src/agent/tools/agent/subagent-task.ts new file mode 100644 index 0000000000000000000000000000000000000000..3f2dc6605e40bf479e5019b256725c50d9ecd9e9 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/agent/subagent-task.ts @@ -0,0 +1,158 @@ +import type { TokenUsage } from '#human/llm/usage'; +import type { SubagentModelSource } from '#/session/subagent/configSection'; + +import { isAbortError } from '#/_base/utils/abort'; +import { ErrorCodes, isError2 } from '#/errors'; +import { REPEAT_BREAKER_STOP_REASON } from '#/agent/toolDedupe/toolDedupe'; +import { + type AgentTask, + type AgentTaskInfoBase, + type AgentTaskSink, +} from '#/agent/task/types'; + +const REPEAT_BREAKER_SETTLE_REASON = + 'stopped by the repeat breaker after issuing the same tool call repeatedly; its output is a handoff, not a finished result'; + +type SubagentCompletion = { + readonly result: string; + readonly usage?: TokenUsage; + readonly stopReason?: string; +}; + +export type SubagentHandle = { + readonly agentId: string; + readonly profileName: string; + readonly parentToolCallId?: string; + readonly model?: string; + readonly modelSource?: SubagentModelSource; + readonly thinkingEffort?: string; + readonly completion: Promise; +}; + +export interface SubagentTaskInfo extends AgentTaskInfoBase { + readonly kind: 'agent'; + readonly agentId?: string; + readonly subagentType?: string; + readonly parentToolCallId?: string; + readonly model?: string; + readonly thinkingEffort?: string; + readonly stopCode?: string; +} + +declare module '#/agent/task/types' { + interface AgentTaskInfoByKind { + readonly agent: SubagentTaskInfo; + } +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function stopCodeOf(error: unknown): string | undefined { + if (!isError2(error)) return undefined; + if (error.code === ErrorCodes.AGENT_NO_FINAL_MESSAGE) { + const stopReason = error.details?.['stopReason']; + if (typeof stopReason === 'string') return stopReason; + } + return error.code; +} + +function completedSettleReason(stopReason: string | undefined): string | undefined { + return stopReason === REPEAT_BREAKER_STOP_REASON ? REPEAT_BREAKER_SETTLE_REASON : undefined; +} + +export function createSubagentExecutor( + handle: SubagentHandle, + abortController: AbortController, +): (signal: AbortSignal, output: (data: string) => void) => Promise { + return async (signal, output) => { + const requestAbort = (): void => { + abortController.abort(signal.reason); + }; + if (signal.aborted) { + requestAbort(); + } else { + signal.addEventListener('abort', requestAbort, { once: true }); + } + + try { + const outcome = await handle.completion; + output(outcome.result); + return outcome; + } catch (error: unknown) { + if (signal.aborted && (isAbortError(error) || error === signal.reason)) { + throw error; + } + throw error; + } finally { + signal.removeEventListener('abort', requestAbort); + } + }; +} + +export class SubagentTask implements AgentTask { + readonly kind = 'agent' as const; + readonly idPrefix: string = 'agent'; + readonly agentId: string; + readonly subagentType: string; + readonly parentToolCallId?: string; + readonly model?: string; + readonly thinkingEffort?: string; + private stopCode: string | undefined; + + constructor( + private readonly handle: SubagentHandle, + readonly description: string, + private readonly abortController: AbortController, + ) { + this.agentId = handle.agentId; + this.subagentType = handle.profileName; + this.parentToolCallId = handle.parentToolCallId; + this.model = handle.model; + this.thinkingEffort = handle.thinkingEffort; + } + + async start(sink: AgentTaskSink): Promise { + const requestAbort = (): void => { + this.abortController.abort(sink.signal.reason); + }; + if (sink.signal.aborted) { + requestAbort(); + } else { + sink.signal.addEventListener('abort', requestAbort, { once: true }); + } + + try { + const outcome = await this.handle.completion; + this.stopCode = outcome.stopReason; + sink.appendOutput(outcome.result); + await sink.settle({ + status: 'completed', + stopReason: completedSettleReason(outcome.stopReason), + }); + } catch (error: unknown) { + if (sink.signal.aborted && (isAbortError(error) || error === sink.signal.reason)) { + await sink.settle({ status: 'killed' }); + return; + } + this.stopCode = stopCodeOf(error); + await sink.settle({ status: 'failed', stopReason: errorMessage(error) }); + } finally { + sink.signal.removeEventListener('abort', requestAbort); + } + } + + toInfo(base: AgentTaskInfoBase): SubagentTaskInfo { + return { + ...base, + kind: 'agent', + agentId: this.agentId, + subagentType: this.subagentType, + parentToolCallId: this.parentToolCallId, + model: this.model, + thinkingEffort: this.thinkingEffort, + stopCode: this.stopCode, + }; + } +} diff --git a/packages/agent-core-v2/src/agent/tools/ask-user-question/ask-user-question.ts b/packages/agent-core-v2/src/agent/tools/ask-user-question/ask-user-question.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf3b6dd50edd33a8c55e08c3557b86134b077b9d --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/ask-user-question/ask-user-question.ts @@ -0,0 +1,94 @@ +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +const QuestionOptionSchema = z.object({ + label: z + .string() + .min(1) + .describe("Concise display text (1-5 words). If recommended, append '(Recommended)'."), + description: z.string().default('').describe('Brief explanation of trade-offs or implications.'), +}); + +const QuestionItemSchema = z.object({ + question: z.string().min(1).describe("A specific, actionable question. End with '?'."), + header: z + .string() + .default('') + .describe("Short category tag (max 12 chars, e.g. 'Auth', 'Style')."), + options: z + .array(QuestionOptionSchema) + .min(2) + .max(4) + .describe( + "2-4 meaningful, distinct options. Do NOT include an 'Other' option — the system adds one automatically.", + ), + multi_select: z + .boolean() + .default(false) + .describe('Whether the user can select multiple options.'), +}); + +export interface AskUserQuestionInput { + background?: boolean; + questions: Array<{ + question: string; + header: string; + options: Array<{ label: string; description: string }>; + multi_select: boolean; + }>; +} + +const QUESTION_UNIQUENESS_MESSAGE = + 'Question texts must be unique across questions, and option labels must be unique within each question.'; + +export function questionUniquenessError( + questions: AskUserQuestionInput['questions'], +): string | null { + const texts = new Set(); + for (const q of questions) { + if (texts.has(q.question)) { + return `Invalid questions: duplicate question text ${JSON.stringify(q.question)}. ${QUESTION_UNIQUENESS_MESSAGE} Rephrase the duplicates and call the tool again.`; + } + texts.add(q.question); + const labels = new Set(); + for (const option of q.options) { + if (labels.has(option.label)) { + return `Invalid questions: duplicate option label ${JSON.stringify(option.label)} in question ${JSON.stringify(q.question)}. ${QUESTION_UNIQUENESS_MESSAGE} Rephrase the duplicates and call the tool again.`; + } + labels.add(option.label); + } + } + return null; +} + +const AskUserQuestionInputBaseSchema = z.object({ + questions: z + .array(QuestionItemSchema) + .min(1) + .max(4) + .describe('The questions to ask the user (1-4 questions).'), +}); + +export const AskUserQuestionInputSchemaWithBackground = AskUserQuestionInputBaseSchema.extend({ + background: z + .boolean() + .default(false) + .describe( + 'Set true to ask in the background and return immediately with a background task_id; you are notified automatically when the user answers — do not poll with TaskOutput while the question is pending.', + ), +}).refine((data) => questionUniquenessError(data.questions) === null, { + message: QUESTION_UNIQUENESS_MESSAGE, +}); + +export const AskUserQuestionInputSchema: z.ZodType = + AskUserQuestionInputBaseSchema.refine( + (data) => questionUniquenessError(data.questions) === null, + { message: QUESTION_UNIQUENESS_MESSAGE }, + ); + +export interface IAskUserQuestionTool extends AgentTool { + readonly _serviceBrand: undefined; +} +export const IAskUserQuestionTool = createDecorator('askUserQuestionTool'); diff --git a/packages/agent-core-v2/src/agent/tools/ask-user-question/ask-user.md b/packages/agent-core-v2/src/agent/tools/ask-user-question/ask-user.md new file mode 100644 index 0000000000000000000000000000000000000000..0b4d966f5cc80d655e0df192b869df23af563bfe --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/ask-user-question/ask-user.md @@ -0,0 +1,21 @@ +Use this tool when you need to ask the user questions with structured options during execution. This allows you to: +1. Collect user preferences or requirements before proceeding +2. Resolve ambiguous or underspecified instructions +3. Let the user decide between implementation approaches as you work +4. Present concrete options when multiple valid directions exist + +**When NOT to use:** +- When you can infer the answer from context — be decisive and proceed +- Trivial decisions that don't materially affect the outcome + +Overusing this tool interrupts the user's flow. Only use it when the user's input genuinely changes your next action. + +**Usage notes:** +- Users always have an "Other" option for custom input — don't create one yourself +- Use multi_select to allow multiple answers to be selected for a question +- Keep option labels concise (1-5 words), use descriptions for trade-offs and details +- Each question should have 2-4 meaningful, distinct options +- Question texts must be unique across the call, and option labels must be unique within each question +- You can ask 1-4 questions at a time; group related questions to minimize interruptions +- If you recommend a specific option, list it first and append "(Recommended)" to its label +- The result is JSON with an `answers` object keyed by question text; each value is the chosen option's label (comma-separated labels for multi_select, or the user's own words if they picked "Other"); if `answers` is empty and a `note` says the user dismissed it, they chose not to answer — do not treat this as selecting the recommended option; decide based on context and do not re-ask the same question diff --git a/packages/agent-core-v2/src/agent/tools/ask-user-question/askUserQuestionTool.ts b/packages/agent-core-v2/src/agent/tools/ask-user-question/askUserQuestionTool.ts new file mode 100644 index 0000000000000000000000000000000000000000..d3da1b1803f3395ba31a6d70febc2a5450ba9510 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/ask-user-question/askUserQuestionTool.ts @@ -0,0 +1,295 @@ +import { randomUUID } from 'node:crypto'; + +import { CoreErrors } from '#/_base/errors/codes'; +import { Error2 } from '#/_base/errors/errors'; +import { toInputJsonSchema } from '#/tool/input-schema'; +import { isAbortError } from '#/_base/utils/abort'; +import { IAgentTaskService } from '#/agent/task/task'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import type { QuestionAnsweredEvent, QuestionDismissedEvent } from '#/app/telemetry/events'; +import type { + ExecutableToolContext, + ExecutableToolResult, + ToolExecution, +} from '#/tool/toolContract'; +import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; + +import { + INTERACTION_TAG_AGENT_ID, + INTERACTION_TAG_SESSION_ID, + INTERACTION_TAG_TOOL_CALL_ID, + INTERACTION_TAG_TURN_ID, + isInteractionCancellation, + type InteractionTags, +} from '#/human/interaction/interaction'; +import { interactions } from '#/human/interaction/facade'; +import type { + QuestionAnswers, + QuestionAnswerMethod, + QuestionRequest, + QuestionResponse, + QuestionResult, +} from '#/agent/interaction/question'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { + AskUserQuestionInputSchema, + AskUserQuestionInputSchemaWithBackground, + IAskUserQuestionTool, + questionUniquenessError, + type AskUserQuestionInput, +} from './ask-user-question'; +import DESCRIPTION from './ask-user.md?raw'; +import { QuestionBackgroundTask } from './question-background-task'; + +const QUESTION_DISMISSED_MESSAGE = 'User dismissed the question without answering.'; + +const QUESTION_UNSUPPORTED_FAILURE_MESSAGE = + 'The connected client does not support interactive questions. Do NOT call this tool again. Ask the user directly in your text response instead.'; + +const BACKGROUND_DESCRIPTION = + '- Set background=true when you can keep working without the answer. This starts a background question task and returns a task_id immediately. The answer arrives automatically in a later turn — you do not need to poll, sleep, or check on it. Continue with other work; never fabricate or predict the answer.'; + +const BACKGROUND_UNAVAILABLE_MESSAGE = + 'Background questions are not available for this agent because TaskList, TaskOutput, and TaskStop are not enabled.'; + +const PARAMETERS_WITH_BACKGROUND = toInputJsonSchema(AskUserQuestionInputSchemaWithBackground); +const PARAMETERS_FOREGROUND_ONLY = toInputJsonSchema(AskUserQuestionInputSchema); + +export class AskUserQuestionTool implements IAskUserQuestionTool { + declare readonly _serviceBrand: undefined; + readonly name = 'AskUserQuestion' as const; + + constructor( + @ISessionContext private readonly session: ISessionContext, + @ITelemetryService private readonly telemetry: ITelemetryService, + @IAgentTaskService private readonly tasks: IAgentTaskService, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, + ) {} + + get description(): string { + return `${DESCRIPTION}${this.allowBackground() ? BACKGROUND_DESCRIPTION : ''}`; + } + + get parameters(): Record { + return this.allowBackground() ? PARAMETERS_WITH_BACKGROUND : PARAMETERS_FOREGROUND_ONLY; + } + + resolveExecution(args: AskUserQuestionInput): ToolExecution { + const isBackground = args.background === true; + return { + description: isBackground + ? `Starting background question: ${questionDescription(args.questions)}` + : 'Asking user questions', + approvalRule: this.name, + execute: (ctx) => this.execution(args, ctx), + }; + } + + private async execution( + args: AskUserQuestionInput, + { toolCallId, signal, turnId, trace }: ExecutableToolContext, + ): Promise { + if (args.background === true && !this.allowBackground()) { + return { isError: true, output: BACKGROUND_UNAVAILABLE_MESSAGE }; + } + + const uniquenessError = questionUniquenessError(args.questions); + if (uniquenessError !== null) { + return { isError: true, output: uniquenessError }; + } + + if (args.background === true) { + return this.executeInBackground(args, { toolCallId, turnId, signal, trace }); + } + + return this.executeQuestion(args, { toolCallId, turnId, signal, trace }); + } + + private allowBackground(): boolean { + return ( + this.toolPolicy.isToolActive('TaskList') && + this.toolPolicy.isToolActive('TaskOutput') && + this.toolPolicy.isToolActive('TaskStop') + ); + } + + private executeInBackground( + args: AskUserQuestionInput, + { + toolCallId, + signal, + turnId, + trace, + }: Pick, + ): ExecutableToolResult { + if (signal.aborted) { + signal.throwIfAborted(); + } + + const description = questionDescription(args.questions); + let taskId: string; + try { + taskId = this.tasks.registerTask( + new QuestionBackgroundTask( + (taskSignal) => this.executeQuestion(args, { toolCallId, turnId, signal: taskSignal, trace }), + description, + { questionCount: args.questions.length, toolCallId }, + ), + { detached: true }, + ); + } catch (error) { + return { + isError: true, + output: error instanceof Error ? error.message : String(error), + }; + } + + const status = this.tasks.getTask(taskId)?.status ?? 'running'; + return { + isError: false, + output: + `task_id: ${taskId}\n` + + `status: ${status}\n` + + 'next_step: Continue your work; the answer arrives automatically in a later message. Use TaskStop only to cancel the question.', + }; + } + + private async executeQuestion( + args: AskUserQuestionInput, + { + toolCallId, + signal, + turnId, + trace, + }: Pick, + ): Promise { + try { + const result = await this.requestQuestion(args, { toolCallId, turnId, signal }); + + const normalized = normalizeQuestionResult(result); + if (normalized === null || Object.keys(normalized.answers).length === 0) { + const properties: QuestionDismissedEvent = { + trace_id: trace?.traceId, + }; + this.telemetry.track2('question_dismissed', properties); + return dismissedQuestionResult(); + } + + const properties: QuestionAnsweredEvent = { + answered: Object.keys(normalized.answers).length, + trace_id: trace?.traceId, + }; + if (normalized.method !== undefined) properties.method = normalized.method; + this.telemetry.track2('question_answered', properties); + return { + isError: false, + output: JSON.stringify({ answers: normalized.answers }), + }; + } catch (error) { + if (isAbortError(error) || signal.aborted) throw error; + + if (error instanceof Error2 && error.code === CoreErrors.codes.NOT_IMPLEMENTED) { + return { + isError: true, + output: QUESTION_UNSUPPORTED_FAILURE_MESSAGE, + }; + } + + return dismissedQuestionResult(); + } + } + + private requestQuestion( + args: AskUserQuestionInput, + { + toolCallId, + signal, + turnId, + }: Pick, + ): Promise { + const id = `question_${randomUUID()}`; + const tags: InteractionTags = { + [INTERACTION_TAG_AGENT_ID]: this.scopeContext.agentId, + [INTERACTION_TAG_SESSION_ID]: this.session.sessionId, + [INTERACTION_TAG_TOOL_CALL_ID]: toolCallId, + }; + if (args.background !== true) tags[INTERACTION_TAG_TURN_ID] = turnId; + const pending = interactions + .request({ + id, + kind: 'question', + payload: { + turnId, + toolCallId, + questions: args.questions.map((q) => ({ + question: q.question, + header: q.header, + options: q.options.map((o) => ({ + label: o.label, + description: o.description, + })), + multiSelect: q.multi_select, + })), + }, + tags, + }) + .then((response) => (isInteractionCancellation(response) ? null : (response as QuestionResult))); + if (signal.aborted) { + interactions.respond(id, null); + } else { + const onAbort = (): void => { + interactions.respond(id, null); + }; + signal.addEventListener('abort', onAbort, { once: true }); + void pending.finally(() => { + signal.removeEventListener('abort', onAbort); + }); + } + return pending; + } +} + +registerAgentToolService(IAskUserQuestionTool, AskUserQuestionTool, { + name: 'AskUserQuestion', + domain: 'questionTools', +}); + +function questionDescription(questions: AskUserQuestionInput['questions']): string { + const first = questions[0]?.question.trim(); + const label = first === undefined || first.length === 0 ? 'Ask user question' : first; + if (questions.length <= 1) return label; + return `${label} (+${String(questions.length - 1)} more)`; +} + +function dismissedQuestionResult(): ExecutableToolResult { + return { + isError: false, + output: JSON.stringify({ + answers: {}, + note: QUESTION_DISMISSED_MESSAGE, + }), + }; +} + +function normalizeQuestionResult( + result: QuestionResult, +): { readonly answers: QuestionAnswers; readonly method?: QuestionAnswerMethod | undefined } | null { + if (result === null) return null; + if (isQuestionResponse(result)) { + return { + answers: result.answers, + method: result.method, + }; + } + return { answers: result }; +} + +function isQuestionResponse(result: Exclude): result is QuestionResponse { + if (typeof result !== 'object' || result === null) return false; + if (!Object.hasOwn(result, 'answers')) return false; + const answers = (result as { readonly answers?: unknown }).answers; + return typeof answers === 'object' && answers !== null && !Array.isArray(answers); +} diff --git a/packages/agent-core-v2/src/agent/tools/ask-user-question/question-background-task.ts b/packages/agent-core-v2/src/agent/tools/ask-user-question/question-background-task.ts new file mode 100644 index 0000000000000000000000000000000000000000..2b432300a9eb3aef7dd981bbfa1174fe00cf6e8d --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/ask-user-question/question-background-task.ts @@ -0,0 +1,68 @@ +import { isAbortError } from '#/_base/utils/abort'; +import { + type AgentTask, + type AgentTaskInfoBase, + type AgentTaskSink, +} from '#/agent/task/types'; +import type { ExecutableToolResult } from '#/tool/toolContract'; + +export interface QuestionTaskInfo extends AgentTaskInfoBase { + readonly kind: 'question'; + readonly questionCount: number; + readonly toolCallId?: string; +} + +declare module '#/agent/task/types' { + interface AgentTaskInfoByKind { + readonly question: QuestionTaskInfo; + } +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +export class QuestionBackgroundTask implements AgentTask { + readonly kind = 'question' as const; + readonly idPrefix: string = 'question'; + private readonly questionCount: number; + private readonly toolCallId?: string; + + constructor( + private readonly run: (signal: AbortSignal) => Promise, + readonly description: string, + info: { questionCount: number; toolCallId?: string }, + ) { + this.questionCount = info.questionCount; + this.toolCallId = info.toolCallId; + } + + async start(sink: AgentTaskSink): Promise { + try { + const result = await this.run(sink.signal); + const output = + typeof result.output === 'string' ? result.output : JSON.stringify(result.output); + if (result.isError === true) { + await sink.settle({ status: 'failed', stopReason: output }); + return; + } + sink.appendOutput(output); + await sink.settle({ status: 'completed' }); + } catch (error: unknown) { + if (sink.signal.aborted && (isAbortError(error) || error === sink.signal.reason)) { + await sink.settle({ status: 'killed' }); + return; + } + await sink.settle({ status: 'failed', stopReason: errorMessage(error) }); + } + } + + toInfo(base: AgentTaskInfoBase): QuestionTaskInfo { + return { + ...base, + kind: 'question', + questionCount: this.questionCount, + toolCallId: this.toolCallId, + }; + } +} diff --git a/packages/agent-core-v2/src/agent/tools/edit/edit.md b/packages/agent-core-v2/src/agent/tools/edit/edit.md new file mode 100644 index 0000000000000000000000000000000000000000..f928fa22fb68b457aee20e300d055f3b75b6308e --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/edit/edit.md @@ -0,0 +1,13 @@ +Perform exact replacements in existing files. + +- Edit is mandatory for every incremental change, especially small edits. DO NOT use Write or Bash `sed`. +- Read the target file before every Edit. DO NOT call Edit from memory, stale context, or a guessed `old_string`. +- Take `old_string` and `new_string` from the Read output view. +- Drop the line-number prefix and tab; match only file content. +- `old_string` must be unique unless `replace_all` is set. +- If `old_string` is ambiguous, add surrounding context. Use `replace_all` only when every occurrence should change — for example, renaming a symbol throughout the file. +- Multiple Edit calls may run in one response only when they do not target the same file. +- DO NOT issue consecutive Edit calls on the same file. A previous Edit can invalidate a later Edit's `old_string`, causing `old_string not found`. Read the file again before the next Edit. +- A write lock serializes same-file edits in response order, but serialization does not make stale `old_string` valid. +- For pure CRLF files, Read shows LF; use LF in `old_string` and `new_string`, and Edit writes CRLF back. +- For mixed endings or lone carriage returns, Read shows carriage returns as \r; include actual \r escapes in those positions. diff --git a/packages/agent-core-v2/src/agent/tools/edit/edit.ts b/packages/agent-core-v2/src/agent/tools/edit/edit.ts new file mode 100644 index 0000000000000000000000000000000000000000..8a5d3acce0f52017404a4f915f8723d19dd3485b --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/edit/edit.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export const EditInputSchema = z.object({ + path: z + .string() + .describe( + 'Path to the text file to edit. Relative paths resolve against the working directory; a path outside the working directory must be absolute.', + ), + old_string: z + .string() + .min(1) + .describe( + 'Exact content to replace from the Read output view, without the line-number prefix. Use LF for pure CRLF files; use actual \\r escapes where Read shows \\r.', + ), + new_string: z + .string() + .describe( + 'Replacement text in the same Read output view. LF is written back as CRLF only for pure CRLF files.', + ), + replace_all: z + .boolean() + .optional() + .describe('Set true only when every occurrence of old_string should be replaced.'), +}); + +export type EditInput = z.infer; + +export interface IEditTool extends AgentTool { + readonly _serviceBrand: undefined; +} +export const IEditTool = createDecorator('editTool'); diff --git a/packages/agent-core-v2/src/agent/tools/edit/editTool.ts b/packages/agent-core-v2/src/agent/tools/edit/editTool.ts new file mode 100644 index 0000000000000000000000000000000000000000..a3ef1f35cb09e9cc1aa42bf9ba2362690bb99b97 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/edit/editTool.ts @@ -0,0 +1,119 @@ +import { + resolvePathAccessPath, + type WorkspaceConfig, +} from '#/tool/path-access'; +import { toInputJsonSchema } from '#/tool/input-schema'; +import { literalRulePattern, matchesPathRuleSubject } from '#/tool/rule-match'; +import { IFileEditService } from '#/app/edit/fileEdit'; +import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import type { Runtime } from '#/runtime/runtime'; +import { RuntimeWorkspaceView } from '#/runtime/runtimeWorkspaceView'; +import { IAgentRuntimeService, inspectAgentRuntime } from '#/agent/runtimeBinding/agentRuntime'; +import { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { + ToolAccesses, + type ExecutableToolResult, + type ToolExecution, +} from '#/tool/toolContract'; +import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; + +import { EditInputSchema, IEditTool, type EditInput } from './edit'; +import editDescriptionTemplate from './edit.md?raw'; + +export class EditTool implements IEditTool { + declare readonly _serviceBrand: undefined; + readonly name = 'Edit' as const; + readonly description = editDescriptionTemplate; + readonly parameters: Record = toInputJsonSchema(EditInputSchema); + + constructor( + @IFileEditService private readonly editor: IFileEditService, + @IAgentRuntimeService private readonly runtime: IAgentRuntimeService, + @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, + @ISessionSkillCatalog private readonly skillCatalog?: ISessionSkillCatalog, + ) {} + + private workspaceConfig(runtime: Runtime): WorkspaceConfig { + const view = new RuntimeWorkspaceView(runtime, { + workDir: this.workspaceCtx.workDir, + additionalDirs: [ + ...this.workspaceCtx.additionalDirs, + ...(this.skillCatalog?.catalog.getSkillRoots() ?? []), + ], + }); + return { workspaceDir: view.workDir, additionalDirs: view.additionalDirs }; + } + + resolveExecution(args: EditInput): ToolExecution { + const inspected = inspectAgentRuntime(this.runtime); + const env = inspected.environment; + const workspace = this.workspaceConfig(inspected); + const path = resolvePathAccessPath(args.path, { + env, + workspace, + operation: 'write', + }); + return { + accesses: ToolAccesses.readWriteFile(path), + description: `Editing ${args.path}`, + display: { + kind: 'file_io', + operation: 'edit', + path, + before: args.old_string, + after: args.new_string, + }, + approvalRule: literalRulePattern(this.name, path), + matchesRule: (ruleArgs) => + matchesPathRuleSubject(ruleArgs, path, { + cwd: workspace.workspaceDir, + pathClass: env.pathClass, + homeDir: env.homeDir, + }), + execute: async () => { + const lease = this.runtime.acquire(['fs']); + try { + if (lease.runtime.identity.generation !== inspected.identity.generation) { + return { isError: true, output: 'Runtime changed before execution. Retry the tool call.' }; + } + return await this.execution(args, path, lease.runtime.fs!); + } finally { + lease.dispose(); + } + }, + }; + } + + private async execution( + args: EditInput, + safePath: string, + fs: IHostFileSystem, + ): Promise { + if (args.old_string === args.new_string) { + return { + isError: true, + output: 'No changes to make: old_string and new_string are exactly the same.', + }; + } + + const result = await this.editor.edit({ + path: safePath, + displayPath: args.path, + old_string: args.old_string, + new_string: args.new_string, + replace_all: args.replace_all ?? false, + }, fs); + if (!result.ok) { + return { isError: true, output: result.error }; + } + const word = result.count === 1 ? 'occurrence' : 'occurrences'; + return { output: `Replaced ${String(result.count)} ${word} in ${args.path}` }; + } +} + +registerAgentToolService(IEditTool, EditTool, { + name: 'Edit', + domain: 'edit', + requiredRuntimeCapabilities: ['fs'], +}); diff --git a/packages/agent-core-v2/src/agent/tools/fetch-url/fetch-url.md b/packages/agent-core-v2/src/agent/tools/fetch-url/fetch-url.md new file mode 100644 index 0000000000000000000000000000000000000000..30e98d6f9a5b4915cdf71ca67e7a9bba46b87358 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/fetch-url/fetch-url.md @@ -0,0 +1,3 @@ +Fetch content from a URL. The content is returned either as the main text extracted from the page, or as the full response body verbatim; a note at the top of the result states which of the two you received, so you can judge how complete it is. Use this when you need to read a specific web page. + +Only fully-formed public `http`/`https` URLs are supported; other schemes and private or loopback addresses are not fetched. Very large pages may be truncated or refused. The fetch carries no login or session for the target site, so pages behind authentication (private repositories, internal dashboards) return a login page or an error instead of the real content — if the text you get back looks like a generic landing or sign-in page, treat that as the login wall, not the answer, and reach the content through a credentialed route (an authenticated CLI or MCP tool) instead. diff --git a/packages/agent-core-v2/src/agent/tools/fetch-url/fetch-url.ts b/packages/agent-core-v2/src/agent/tools/fetch-url/fetch-url.ts new file mode 100644 index 0000000000000000000000000000000000000000..d4a25bed824eb6bc5b2abc27c75c27479c27eb53 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/fetch-url/fetch-url.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export const FetchURLInputSchema = z.object({ + url: z.string().describe('The URL to fetch content from.'), +}); + +export type FetchURLInput = z.infer; + +export interface IFetchURLTool extends AgentTool { + readonly _serviceBrand: undefined; +} +export const IFetchURLTool = createDecorator('fetchURLTool'); diff --git a/packages/agent-core-v2/src/agent/tools/fetch-url/fetchUrlTool.ts b/packages/agent-core-v2/src/agent/tools/fetch-url/fetchUrlTool.ts new file mode 100644 index 0000000000000000000000000000000000000000..9954ee42cbecf8ba2fd5fa9a5fb5c7431d4f7dbe --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/fetch-url/fetchUrlTool.ts @@ -0,0 +1,79 @@ +import { toInputJsonSchema } from '#/tool/input-schema'; +import { literalRulePattern, matchesGlobRuleSubject } from '#/tool/rule-match'; +import { + ToolAccesses, + type ExecutableToolContext, + type ExecutableToolResult, + type ToolExecution, +} from '#/tool/toolContract'; +import { ToolOutputAccumulator } from '#/tool/output-accumulator'; +import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; + +import { IWebFetchService } from '#/app/web/web'; +import { HttpFetchError } from '#/app/web/tools/fetch-url-types'; +import { FetchURLInputSchema, IFetchURLTool, type FetchURLInput } from './fetch-url'; +import DESCRIPTION from './fetch-url.md?raw'; + +export class FetchURLTool implements IFetchURLTool { + declare readonly _serviceBrand: undefined; + readonly name = 'FetchURL' as const; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(FetchURLInputSchema); + + constructor(@IWebFetchService private readonly webFetch: IWebFetchService) {} + + resolveExecution(args: FetchURLInput): ToolExecution { + const preview = args.url.length > 50 ? `${args.url.slice(0, 50)}…` : args.url; + return { + accesses: ToolAccesses.none(), + description: `Fetching: ${preview}`, + display: { kind: 'url_fetch', url: args.url }, + approvalRule: literalRulePattern(this.name, args.url), + matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.url), + execute: (ctx) => this.execution(args, ctx), + }; + } + + private async execution( + args: FetchURLInput, + { toolCallId, signal }: ExecutableToolContext, + ): Promise { + try { + const { content, kind } = await this.webFetch + .getUrlFetcher() + .fetch(args.url, { toolCallId, signal }); + + if (!content) { + return { + output: 'The response body is empty.', + isError: false, + }; + } + + const builder = new ToolOutputAccumulator(); + const note = + kind === 'passthrough' + ? 'The returned content is the full response body, returned verbatim.' + : 'The returned content is the main text extracted from the page.'; + const citeReminder = + 'If you use it in your answer, cite this page as a markdown link, e.g. [title](url).'; + builder.write(`${note} ${citeReminder}\n\n${content}`); + return builder.ok(); + } catch (error) { + if (signal.aborted) throw error; + const msg = error instanceof Error ? error.message : String(error); + if (error instanceof HttpFetchError) { + return { + isError: true, + output: `Failed to fetch URL. Status: ${String(error.status)}. ${msg}`, + }; + } + return { + isError: true, + output: `Failed to fetch URL due to network error: ${args.url}. ${msg}`, + }; + } + } +} + +registerAgentToolService(IFetchURLTool, FetchURLTool, { name: 'FetchURL', domain: 'web' }); diff --git a/packages/agent-core-v2/src/agent/tools/fileReadSource.ts b/packages/agent-core-v2/src/agent/tools/fileReadSource.ts new file mode 100644 index 0000000000000000000000000000000000000000..f6b8026d9762f0e9d7f70bbad6acf5365e8cf1e3 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/fileReadSource.ts @@ -0,0 +1,64 @@ +import { readUtf8Lines } from '#/_base/execEnv/decodeText'; +import type { HostFileStat, IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { parseDaemonFileUrl } from '#/agent/media/mediaRef'; +import type { ISessionMediaStore } from '#/agent/media/sessionMediaStore'; +import type { ExecutableToolResult } from '#/tool/toolContract'; + +export interface FileReadSource { + readonly name: string; + readonly localPath?: string; + stat(): Promise; + readBytes(n?: number): Promise; + readLines(): AsyncIterable; +} + +export function withAttachmentLocation(result: ExecutableToolResult, source: FileReadSource): ExecutableToolResult { + if (!result.isError || source.localPath === undefined || typeof result.output !== 'string') return result; + return { ...result, output: `${result.output}\nServer-local attachment path: ${JSON.stringify(source.localPath)}` }; +} + +export function runtimeFileSource(fs: IHostFileSystem, path: string): FileReadSource { + return { + name: path, + stat: () => fs.stat(path), + readBytes: (n) => fs.readBytes(path, n), + readLines: () => fs.readLines(path, { errors: 'strict' }), + }; +} + +export async function attachmentFileSource(reference: string, store?: ISessionMediaStore): Promise { + const ref = parseDaemonFileUrl(reference); + const open = async () => { + const file = ref === undefined ? undefined : await store?.open(ref.fileId); + if (file === undefined) throw new Error(`Attachment ${JSON.stringify(reference)} is not available in the current session.`); + return file; + }; + const initial = await open(); + return { + name: initial.name, + localPath: initial.path, + stat: async () => ({ isFile: true, isDirectory: false, size: (await open()).size }), + readBytes: async (n) => { + const file = await open(); + const size = Math.min(n ?? file.size, file.size); + if (size === 0) return new Uint8Array(); + const chunks: Buffer[] = []; + for await (const chunk of file.stream({ start: 0, end: size - 1 })) chunks.push(Buffer.from(chunk)); + const bytes = Buffer.concat(chunks); + if (bytes.length !== size) throw new Error('Attachment changed or became unavailable while reading.'); + return bytes; + }, + readLines: async function* () { + const file = await open(); + const checkedStream = async function* () { + let size = 0; + for await (const chunk of file.stream()) { + size += chunk.length; + yield chunk; + } + if (size !== file.size) throw new Error('Attachment changed or became unavailable while reading.'); + }; + yield* readUtf8Lines(checkedStream()); + }, + }; +} diff --git a/packages/agent-core-v2/src/agent/tools/mainAgentOnly.ts b/packages/agent-core-v2/src/agent/tools/mainAgentOnly.ts new file mode 100644 index 0000000000000000000000000000000000000000..77dccbe4d1befe7d4d629c81f0703c4197a5250f --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/mainAgentOnly.ts @@ -0,0 +1,15 @@ +import type { ToolExecution } from '#/tool/toolContract'; +import type { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; + +export const CRON_MAIN_AGENT_ONLY = 'Cron tools are only supported by the main agent.'; + +export const GOAL_MAIN_AGENT_ONLY = 'Goal tools are only supported by the main agent.'; + +export function mainAgentOnlyExecution( + scopeContext: IAgentScopeContext, + output: string, +): ToolExecution | undefined { + if (scopeContext.agentId === MAIN_AGENT_ID) return undefined; + return { isError: true, output }; +} diff --git a/packages/agent-core-v2/src/agent/tools/os/bash/bash.md b/packages/agent-core-v2/src/agent/tools/os/bash/bash.md new file mode 100644 index 0000000000000000000000000000000000000000..3cf6bfeb0b146d1f551f60185d9d2923d7e7e776 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/os/bash/bash.md @@ -0,0 +1,44 @@ +Execute a `${SHELL_NAME}` command. Use this for shell semantics — pipes, env, processes, git, package managers, build/test runners, anything genuinely interactive or multi-step. + +**Translate these to a dedicated tool instead:** +- `cat` / `head` / `tail` (known path) → `Read` +- `sed` / `awk` (in-place edit) → `Edit` +- `echo > file` / `cat <` is fine for listing a directory) +- `grep` / `rg` (search file contents) → `Grep` +- `echo` / `printf` (talk to the user) → just output text directly + +The dedicated tools render in the per-tool permission UI and keep raw stdout out of the conversation; that is why they are worth reaching for whenever one fits. + +**Output:** +The stdout and stderr will be combined and returned as a string. The output may be truncated if it is too long. If the command exits non-zero, the output ends with a `Command failed with exit code: N` line; a command killed by its timeout or interrupted by the user ends with its own message instead. + +If `run_in_background=true`, the command will be started as a background task and this tool will return a task ID instead of waiting for command completion. When doing that, you must provide a short `description`. Background commands default to a ${DEFAULT_BACKGROUND_TIMEOUT_S}s timeout and `timeout` is capped at ${MAX_BACKGROUND_TIMEOUT_S}s; set `disable_timeout=true` only when the task should run without a timeout. You will be automatically notified when the task completes. After starting one, default to returning control to the user instead of immediately waiting on it. Use `TaskOutput` only for a non-blocking status/output snapshot — do not wait on a task you just launched, since its completion arrives automatically. Use `TaskStop` only if the task must be cancelled. If a human user wants to inspect background tasks themselves, point them to the background-task panel. + +**Guidelines for safety and security:** +- Each shell tool call will be executed in a fresh shell environment. The shell variables, current working directory changes, and the shell history is not preserved between calls. To run a command in a particular directory, pass the `cwd` argument (or use absolute paths) rather than relying on a `cd` from an earlier call. +- The tool call will return after the command is finished. You shall not use this tool to execute an interactive command or a command that may run forever. For possibly long-running foreground commands, set the `timeout` argument in seconds. Foreground commands default to ${DEFAULT_TIMEOUT_S}s and allow up to ${MAX_TIMEOUT_S}s. When a foreground command hits its timeout it is moved to the background instead of being killed, and you will be automatically notified when it completes. The user can also move a running foreground command to the background at any time. +- Avoid using `..` to access files or directories outside of the working directory. +- Avoid modifying files outside of the working directory unless explicitly instructed to do so. +- Never run commands that require superuser privileges unless explicitly instructed to do so. +- Run git-mutating commands such as `git commit`, `git push`, `git reset`, and `git rebase` only when the user asks for them. + +**Guidelines for efficiency:** +- Use `&&` to chain commands that genuinely depend on each other, e.g. `npm install && npm test`. Independent read-only commands (separate `git show`, `ls`, or status checks) should be issued as separate parallel Bash calls in one response, not chained into a single call — chaining serializes their execution and mixes their output. Do not stitch outputs together with `echo` separators. +- Use `;` to run commands sequentially regardless of success/failure +- Use `||` for conditional execution (run second command only if first fails) +- Use pipe operations (`|`) and redirections (`>`, `>>`) to chain input and output between commands +- Always quote file paths containing spaces with double quotes (e.g., cd "/path with spaces/") +- Compose multi-step logic in a single call with `if` / `case` / `for` / `while` control flows. +- Prefer `run_in_background=true` for long-running builds, tests, watchers, or servers when you need the conversation to continue before the command finishes. + +**Commands available:** +The following common command categories are usually available. Availability still depends on the host, so when in doubt run `which ` first to confirm a command exists before relying on it. +- Navigation and inspection: `ls`, `pwd`, `cd`, `stat`, `file`, `du`, `df`, `tree` +- File and directory management: `cp`, `mv`, `rm`, `mkdir`, `touch`, `ln`, `chmod`, `chown` +- Text and data processing: `wc`, `sort`, `uniq`, `cut`, `tr`, `diff`, `xargs` +- Archives and compression: `tar`, `gzip`, `gunzip`, `zip`, `unzip` +- Networking and transfer: `curl`, `wget`, `ping`, `ssh`, `scp` +- Version control: `git`; for GitHub-hosted work (PRs, issues, CI runs, API queries) prefer the `gh` CLI when installed — it carries the user's GitHub auth and can return structured JSON +- Process and system: `ps`, `kill`, `top`, `env`, `date`, `uname`, `whoami` +- Language and package toolchains: `node`, `npm`, `pnpm`, `yarn`, `python`, `pip` (use whichever the project actually relies on) diff --git a/packages/agent-core-v2/src/agent/tools/os/bash/bash.ts b/packages/agent-core-v2/src/agent/tools/os/bash/bash.ts new file mode 100644 index 0000000000000000000000000000000000000000..975fa867254be97be1f65160e4b237d0d2f4e558 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/os/bash/bash.ts @@ -0,0 +1,77 @@ +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export const DEFAULT_TIMEOUT_S = 60; +export const MAX_TIMEOUT_S = 5 * 60; +export const DEFAULT_BACKGROUND_TIMEOUT_S = 10 * 60; +export const MAX_BACKGROUND_TIMEOUT_S = 24 * 60 * 60; + +function timeoutCapS(isBackground: boolean): number { + return isBackground ? MAX_BACKGROUND_TIMEOUT_S : MAX_TIMEOUT_S; +} + +function isValidTimeoutValue(timeout: number, isBackground: boolean): boolean { + return timeout <= timeoutCapS(isBackground); +} + +export const BashInputSchema = z + .object({ + command: z.string().min(1, 'Command cannot be empty.').describe('The command to execute.'), + cwd: z + .string() + .optional() + .describe( + "The working directory in which to run the command. When omitted, the command runs in the session's working directory.", + ), + timeout: z + .number() + .int() + .positive() + .default(DEFAULT_TIMEOUT_S) + .describe( + `Optional timeout in seconds for the command to execute. Foreground default ${String(DEFAULT_TIMEOUT_S)}s, max ${String(MAX_TIMEOUT_S)}s. Background default ${String(DEFAULT_BACKGROUND_TIMEOUT_S)}s, max ${String(MAX_BACKGROUND_TIMEOUT_S)}s. Ignored for background commands when disable_timeout=true.`, + ) + .optional(), + description: z + .string() + .optional() + .describe( + 'A short description for the background task. Required when run_in_background is true.', + ), + run_in_background: z + .boolean() + .optional() + .describe('Whether to run the command as a background task.'), + disable_timeout: z + .boolean() + .optional() + .describe( + 'If true, do not apply a timeout to the command. Only applies when run_in_background is true.', + ), + }) + .superRefine((val, ctx) => { + if (val.timeout === undefined) return; + const isBackground = val.run_in_background === true; + if (!isValidTimeoutValue(val.timeout, isBackground)) { + const cap = isBackground ? MAX_BACKGROUND_TIMEOUT_S : MAX_TIMEOUT_S; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['timeout'], + message: `timeout must be ≤ ${String(cap)}s (${isBackground ? 'background' : 'foreground'})`, + }); + } + }); + +export const BashOutputSchema = z.object({ + exitCode: z.number().int(), + stdout: z.string(), + stderr: z.string(), +}); + +export type BashInput = z.infer; +export type BashOutput = z.infer; + +export interface IBashTool extends AgentTool { readonly _serviceBrand: undefined } +export const IBashTool = createDecorator('bashTool'); diff --git a/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts b/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts new file mode 100644 index 0000000000000000000000000000000000000000..41090010ce96a7c66586fda8dc17b3a19d62ceaf --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts @@ -0,0 +1,480 @@ +import { IAgentTaskService } from '#/agent/task/task'; +import { resolveAgentTaskConfig } from '#/agent/task/configSection'; +import { IConfigService } from '#/app/config/config'; +import type { HostEnvironmentInfo } from '#/os/interface/hostEnvironment'; +import type { IHostProcess, IHostProcessService } from '#/os/interface/hostProcess'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { IAgentRuntimeService, inspectAgentRuntime } from '#/agent/runtimeBinding/agentRuntime'; +import { RuntimeWorkspaceView } from '#/runtime/runtimeWorkspaceView'; +import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; +import { getShellPathBridge } from '#/_base/execEnv/shellPathBridge'; +import { + DEFAULT_TOOL_RESULT_MAX_CHARS, + type ExecutableToolResult, + type ToolExecution, + type ToolUpdate, +} from '#/tool/toolContract'; +import { + type ToolOutputAccumulatorResult, + ToolOutputAccumulator, +} from '#/tool/output-accumulator'; +import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; +import { toInputJsonSchema } from '#/tool/input-schema'; +import { literalRulePattern, matchesGlobRuleSubject } from '#/tool/rule-match'; +import { renderPrompt } from '#/_base/utils/render-prompt'; +import { userCancellationReason } from '#/_base/utils/abort'; +import bashDescriptionTemplate from './bash.md?raw'; +import { ProcessTask } from './process-task'; +import { + type BashInput, + BashInputSchema, + DEFAULT_BACKGROUND_TIMEOUT_S, + DEFAULT_TIMEOUT_S, + IBashTool, + MAX_BACKGROUND_TIMEOUT_S, + MAX_TIMEOUT_S, +} from './bash'; + +const MS_PER_SECOND = 1000; + +const SHELL_TIMEOUT_VARS = { + DEFAULT_TIMEOUT_S, + DEFAULT_BACKGROUND_TIMEOUT_S, + MAX_TIMEOUT_S, + MAX_BACKGROUND_TIMEOUT_S, +}; + +function timeoutCapS(isBackground: boolean): number { + return isBackground ? MAX_BACKGROUND_TIMEOUT_S : MAX_TIMEOUT_S; +} + +function normalizeTimeoutMs(timeout: number | undefined, isBackground: boolean): number { + const defaultSeconds = isBackground ? DEFAULT_BACKGROUND_TIMEOUT_S : DEFAULT_TIMEOUT_S; + const value = timeout ?? defaultSeconds; + return Math.min(value, timeoutCapS(isBackground)) * MS_PER_SECOND; +} + +async function disposeProcess(proc: IHostProcess): Promise { + try { + await proc.dispose(); + } catch { + } +} + +function renderBashDescription(shellName: string): string { + return renderPrompt(bashDescriptionTemplate, { ...SHELL_TIMEOUT_VARS, SHELL_NAME: shellName }); +} + +function withoutBackgroundDescription(description: string): string { + return description + .replace( + /\r?\n\r?\nIf `run_in_background=true`,[\s\S]*?point them to the background-task panel\./, + '\n\nBackground execution is disabled for this agent. Do not set `run_in_background=true`.', + ) + .replace( + ` For possibly long-running foreground commands, set the \`timeout\` argument in seconds. Foreground commands default to ${String(DEFAULT_TIMEOUT_S)}s and allow up to ${String(MAX_TIMEOUT_S)}s. When a foreground command hits its timeout it is moved to the background instead of being killed, and you will be automatically notified when it completes.`, + ` For possibly long-running commands, set the \`timeout\` argument in seconds. The default is ${String(DEFAULT_TIMEOUT_S)}s; foreground commands allow up to ${String(MAX_TIMEOUT_S)}s; a foreground command that hits its timeout is killed.`, + ) + .replace( + ' The user can also move a running foreground command to the background at any time.', + '', + ) + .replace( + /\r?\n- Prefer `run_in_background=true`[\s\S]*?conversation to continue before the command finishes\./, + '\n- Do not set `run_in_background=true`; background task management tools are not available.', + ); +} + +function withoutAutoBackgroundOnTimeout(description: string): string { + return description.replace( + ' When a foreground command hits its timeout it is moved to the background instead of being killed, and you will be automatically notified when it completes.', + ' A foreground command that hits its timeout is killed.', + ); +} + +export class BashTool implements IBashTool { + declare readonly _serviceBrand: undefined; + readonly name = 'Bash' as const; + readonly parameters: Record = toInputJsonSchema(BashInputSchema); + + constructor( + @IAgentRuntimeService private readonly runtime: IAgentRuntimeService, + @ISessionContext private readonly ctx: ISessionContext, + @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, + @IAgentTaskService private readonly tasks: IAgentTaskService, + @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, + @IConfigService private readonly config: IConfigService, + ) {} + + private allowBackground(): boolean { + return ( + this.toolPolicy.isToolActive('TaskList') && + this.toolPolicy.isToolActive('TaskOutput') && + this.toolPolicy.isToolActive('TaskStop') + ); + } + + private autoBackgroundOnTimeout(): boolean { + return resolveAgentTaskConfig(this.config)?.bashAutoBackgroundOnTimeout ?? true; + } + + private detachTimeoutMs(): number { + const configuredS = resolveAgentTaskConfig(this.config)?.bashTaskTimeoutS; + if (configuredS === undefined) return DEFAULT_BACKGROUND_TIMEOUT_S * MS_PER_SECOND; + return configuredS * MS_PER_SECOND; + } + + get description(): string { + const renderedDescription = renderBashDescription(inspectAgentRuntime(this.runtime).environment.shellName); + if (!this.allowBackground()) return withoutBackgroundDescription(renderedDescription); + if (!this.autoBackgroundOnTimeout()) { + return withoutAutoBackgroundOnTimeout(renderedDescription); + } + return renderedDescription; + } + + resolveExecution(args: BashInput): ToolExecution { + const preview = args.command.length > 50 ? `${args.command.slice(0, 50)}…` : args.command; + return { + description: args.run_in_background + ? `Starting background: ${preview}` + : `Running: ${preview}`, + display: { + kind: 'command', + command: args.command, + cwd: args.cwd ?? this.ctx.cwd, + description: args.description, + language: 'bash', + }, + approvalRule: literalRulePattern(this.name, args.command), + matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.command), + execute: ({ signal, onUpdate, onForegroundTaskStart, toolCallId }) => + this.execution(args, signal, toolCallId, onUpdate, onForegroundTaskStart), + }; + } + + private spawn( + processService: IHostProcessService, + env: HostEnvironmentInfo, + effectiveCwd: string, + command: string, + ): Promise { + const shellCwd = getShellPathBridge(env).toShellPath(effectiveCwd); + const shellCommand = `cd ${shellQuote(shellCwd)} && ${command}`; + const noninteractiveEnv: Record = { + NO_COLOR: '1', + TERM: 'dumb', + GIT_TERMINAL_PROMPT: process.env['GIT_TERMINAL_PROMPT'] ?? '0', + SHELL: env.shellPath, + }; + + return processService.spawn(env.shellPath, ['-c', shellCommand], { env: noninteractiveEnv }); + } + + private async execution( + args: BashInput, + signal: AbortSignal, + toolCallId: string, + onUpdate?: (update: ToolUpdate) => void, + onForegroundTaskStart?: (taskId: string) => void, + ): Promise { + const validationError = this.validateRunRequest(args, signal); + if (validationError !== undefined) return validationError; + + const startsInBackground = args.run_in_background === true; + const foregroundTimeoutMs = normalizeTimeoutMs(args.timeout, false); + const lease = this.runtime.acquire(['process']); + const view = new RuntimeWorkspaceView(lease.runtime, this.workspaceCtx); + const env = lease.runtime.environment; + const command = env.osKind === 'Windows' ? rewriteWindowsNullRedirect(args.command) : args.command; + const effectiveCwd = view.resolve(args.cwd ?? view.workDir); + const description = startsInBackground ? args.description!.trim() : foregroundDescription(args); + const timeoutMs = startsInBackground + ? args.disable_timeout + ? undefined + : normalizeTimeoutMs(args.timeout, true) + : foregroundTimeoutMs; + + const builder = new ToolOutputAccumulator(); + let proc: IHostProcess; + try { + proc = lease.track(await this.spawn(lease.runtime.process!, env, effectiveCwd, command)); + } catch (error) { + lease.dispose(); + return { + isError: true, + output: error instanceof Error ? error.message : String(error), + }; + } + closeProcessStdin(proc); + + let collectForegroundOutput = !startsInBackground; + let foregroundOutputPersisted = false; + let foregroundTaskId: string | undefined; + const onProcessOutput = startsInBackground + ? undefined + : (kind: 'stdout' | 'stderr', text: string): void => { + if (!collectForegroundOutput) return; + onUpdate?.({ kind, text }); + builder.write(text); + if ( + !foregroundOutputPersisted && + builder.totalChars > DEFAULT_TOOL_RESULT_MAX_CHARS && + foregroundTaskId !== undefined + ) { + this.tasks.persistOutput(foregroundTaskId); + foregroundOutputPersisted = true; + } + }; + + let taskId: string; + try { + taskId = this.tasks.registerTask( + new ProcessTask(proc, command, description, onProcessOutput, () => lease.dispose(), toolCallId), + { + detached: startsInBackground, + timeoutMs, + detachTimeoutMs: this.detachTimeoutMs(), + autoBackgroundOnTimeout: this.allowBackground() && this.autoBackgroundOnTimeout(), + signal: startsInBackground ? undefined : signal, + }, + ); + foregroundTaskId = startsInBackground ? undefined : taskId; + } catch (error) { + collectForegroundOutput = false; + await killSpawnedProcess(proc); + lease.dispose(); + return { + isError: true, + output: error instanceof Error ? error.message : String(error), + }; + } + + if (!startsInBackground) onForegroundTaskStart?.(taskId); + + if (startsInBackground) { + return this.backgroundStartedResult(taskId, proc, description, { + title: 'Background task started', + brief: `Started ${taskId}`, + }); + } + + try { + const release = await this.tasks.waitForForegroundRelease(taskId); + if (release === 'detached' || release === 'timeout_detached') { + collectForegroundOutput = false; + const labels = + release === 'timeout_detached' + ? { + title: 'Command timed out and moved to background', + brief: `Backgrounded ${taskId} after timeout`, + } + : { + title: 'Task moved to background by the user', + brief: `Backgrounded ${taskId} by the user`, + }; + return this.backgroundStartedResult( + taskId, + proc, + description, + labels, + builder, + release === 'timeout_detached' ? 'foreground_detached' : 'foreground_detached_by_user', + ); + } + + return await this.foregroundCompletionResult(taskId, proc, builder, foregroundTimeoutMs); + } finally { + collectForegroundOutput = false; + } + } + + private validateRunRequest( + args: BashInput, + signal: AbortSignal, + ): ExecutableToolResult | undefined { + if (signal.aborted) return { isError: true, output: 'Aborted before command started' }; + if (args.command.length === 0) return { isError: true, output: 'Command cannot be empty.' }; + if (args.run_in_background !== true) return undefined; + if (!this.allowBackground()) { + return { + isError: true, + output: + 'Background execution is not available for this agent because TaskOutput and TaskStop are not enabled.', + }; + } + if (!args.description?.trim()) { + return { + isError: true, + output: 'description is required when run_in_background is true.', + }; + } + return undefined; + } + + private async foregroundCompletionResult( + taskId: string, + proc: IHostProcess, + builder: ToolOutputAccumulator, + foregroundTimeoutMs: number, + ): Promise { + const current = this.tasks.getTask(taskId); + const exitCode = current?.kind === 'process' ? current.exitCode : proc.exitCode; + let result: ToolOutputAccumulatorResult; + if (current?.status === 'timed_out') { + const timeoutLabel = formatTimeoutLabel(foregroundTimeoutMs); + result = builder.error(`Command killed by timeout (${timeoutLabel})`, { + brief: `Killed by timeout (${timeoutLabel})`, + }); + } else if ( + current?.status === 'killed' && + current.stopReason === userCancellationReason().message + ) { + result = builder.error('Interrupted by user', { brief: 'Interrupted by user' }); + } else if ( + (current?.status === 'failed' || current?.status === 'killed') && + current.stopReason !== undefined + ) { + result = builder.error(current.stopReason, { brief: current.stopReason }); + } else if (exitCode === 0) { + result = builder.ok('Command executed successfully.'); + } else { + if (builder.nChars === 0) builder.write(`Process exited with code ${String(exitCode)}`); + result = builder.error(`Command failed with exit code: ${String(exitCode)}.`, { + brief: `Failed with exit code: ${String(exitCode)}`, + }); + } + return this.addForegroundOutputReference(taskId, result, builder.totalChars); + } + + private async addForegroundOutputReference( + taskId: string, + result: ToolOutputAccumulatorResult, + totalChars: number, + ): Promise { + if (totalChars <= DEFAULT_TOOL_RESULT_MAX_CHARS) return result; + const output = await this.tasks.getOutputSnapshot(taskId, 0); + if (!output.fullOutputAvailable || output.outputPath === undefined) { + return result; + } + + const taskOutputHint = this.allowBackground() + ? `\nnext_step: Use TaskOutput(task_id="${taskId}") to query the task output.` + : ''; + const taskInfo = `task_id: ${taskId}\noutput_size_bytes: ${String(output.outputSizeBytes)}${taskOutputHint}`; + const existingSuffix = result.spill?.suffix; + return { + ...result, + spill: { + outputPath: output.outputPath, + totalChars, + suffix: existingSuffix !== undefined ? `${existingSuffix}\n${taskInfo}` : taskInfo, + }, + }; + } + + private backgroundStartedResult( + taskId: string, + proc: IHostProcess, + description: string, + labels: { title: string; brief: string }, + builder = new ToolOutputAccumulator(), + scenario: 'background_started' | 'foreground_detached' | 'foreground_detached_by_user' = 'background_started', + ): ExecutableToolResult { + const status = this.tasks.getTask(taskId)?.status ?? 'running'; + const detachedByUser = scenario === 'foreground_detached_by_user' ? 'detached_by_user: true\n' : ''; + const metadata = + `task_id: ${taskId}\n` + + `pid: ${String(proc.pid)}\n` + + `description: ${description}\n` + + `status: ${status}\n` + + detachedByUser + + `automatic_notification: true\n` + + this.nextStepLines(scenario) + + 'human_shell_hint: The task is visible in the background-task panel.'; + + const foregroundResult = builder.ok(''); + const foregroundOutput = foregroundResult.output.length > 0 ? foregroundResult.output : ''; + const result: ExecutableToolResult & { + readonly brief: string; + } = { + isError: false, + output: + foregroundOutput.length === 0 + ? metadata + : `${metadata}\n\nforeground_output:\n${foregroundOutput}`, + brief: labels.brief, + }; + return result; + } + + private nextStepLines( + scenario: 'background_started' | 'foreground_detached' | 'foreground_detached_by_user', + ): string { + if (scenario === 'foreground_detached' || scenario === 'foreground_detached_by_user') { + const avoid = this.allowBackground() + ? 'do NOT wait, poll, or call TaskOutput on it' + : 'do NOT wait or poll'; + const moved = + scenario === 'foreground_detached_by_user' + ? 'The user moved this task to the background.' + : 'The task now runs in the background.'; + return ( + `next_step: ${moved} You will be automatically notified ` + + `when it completes — ${avoid}; continue with your current work.\n` + ); + } + if (!this.allowBackground()) { + return 'next_step: You will be automatically notified when it completes.\n'; + } + return ( + 'next_step: The completion arrives automatically in a later turn — do NOT wait, poll, ' + + 'or call TaskOutput on it; continue with your current work.\n' + + 'next_step: Use TaskStop only if the task must be cancelled.\n' + ); + } +} + +registerAgentToolService(IBashTool, BashTool, { + name: 'Bash', + domain: 'os/backends', + requiredRuntimeCapabilities: ['process'], +}); + +function formatTimeoutLabel(timeoutMs: number): string { + return timeoutMs % 1000 === 0 ? `${String(timeoutMs / 1000)}s` : `${String(timeoutMs)}ms`; +} + +function foregroundDescription(args: BashInput): string { + const explicit = args.description?.trim(); + if (explicit !== undefined && explicit.length > 0) return explicit; + const preview = args.command.length > 60 ? `${args.command.slice(0, 60)}…` : args.command; + return `Bash: ${preview}`; +} + +function closeProcessStdin(proc: IHostProcess): void { + try { + proc.stdin.end(); + } catch { + } +} + +async function killSpawnedProcess(proc: IHostProcess): Promise { + try { + await proc.kill('SIGTERM'); + } catch { + } finally { + await disposeProcess(proc); + } +} + +function shellQuote(s: string): string { + return `'${s.replaceAll("'", "'\\''")}'`; +} + +const WINDOWS_NUL_REDIRECT = /(\d?&?>+\s*)[Nn][Uu][Ll](?=\s|$|[|&;)\n])/g; + +function rewriteWindowsNullRedirect(command: string): string { + return command.replace(WINDOWS_NUL_REDIRECT, '$1/dev/null'); +} diff --git a/packages/agent-core-v2/src/agent/tools/os/bash/process-task.ts b/packages/agent-core-v2/src/agent/tools/os/bash/process-task.ts new file mode 100644 index 0000000000000000000000000000000000000000..fe66797c7c61fc942c155aca8344a3765f14d632 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/os/bash/process-task.ts @@ -0,0 +1,307 @@ +import type { Readable } from 'node:stream'; + +import type { IHostProcess } from '#/os/interface/hostProcess'; + +type ProcessHandle = Omit; + +import type { + AgentTask, + AgentTaskInfoBase, + AgentTaskSink, + AgentTaskSettlement, +} from '#/agent/task/types'; + +export interface ProcessTaskInfo extends AgentTaskInfoBase { + readonly kind: 'process'; + readonly command: string; + readonly pid: number; + readonly exitCode: number | null; + readonly parentToolCallId?: string; +} + +declare module '#/agent/task/types' { + interface AgentTaskInfoByKind { + readonly process: ProcessTaskInfo; + } +} + +export type ProcessTaskOutputKind = 'stdout' | 'stderr'; + +export type ProcessTaskOutputCallback = ( + kind: ProcessTaskOutputKind, + text: string, +) => void; + +const STREAM_DRAIN_GRACE_MS = 250; + +export class ProcessTask implements AgentTask { + readonly kind = 'process' as const; + readonly idPrefix = 'bash'; + private exitCode: number | null = null; + + constructor( + readonly proc: ProcessHandle, + readonly command: string, + readonly description: string, + private readonly onOutput?: ProcessTaskOutputCallback, + private release?: () => void, + readonly parentToolCallId?: string, + ) {} + + async start(sink: AgentTaskSink): Promise { + const streamDrained = Promise.all([ + observeProcessStream(this.proc.stdout, 'stdout', sink, this.onOutput), + observeProcessStream(this.proc.stderr, 'stderr', sink, this.onOutput), + ]).then(() => undefined); + void streamDrained.catch(() => {}); + + const requestStop = (): void => { + void this.proc.kill('SIGTERM').catch(() => {}); + }; + if (sink.signal.aborted) { + requestStop(); + } else { + sink.signal.addEventListener('abort', requestStop, { once: true }); + } + + let settlement: AgentTaskSettlement; + try { + const exitCode = await this.proc.wait(); + await waitForStreamDrain(streamDrained); + this.exitCode = exitCode; + settlement = { + status: sink.signal.aborted ? 'killed' : exitCode === 0 ? 'completed' : 'failed', + }; + } catch (error: unknown) { + await waitForStreamDrainSettled(streamDrained); + this.exitCode = this.proc.exitCode; + settlement = { + status: sink.signal.aborted ? 'killed' : 'failed', + stopReason: sink.signal.aborted ? undefined : errorMessage(error), + }; + } finally { + sink.signal.removeEventListener('abort', requestStop); + await this.disposeProcess(); + } + await sink.settle(settlement); + } + + async forceStop(): Promise { + try { + if (this.proc.exitCode === null) { + await this.proc.kill('SIGKILL'); + } + } finally { + await this.disposeProcess(); + } + } + + toInfo(base: AgentTaskInfoBase): ProcessTaskInfo { + return { + ...base, + kind: 'process', + command: this.command, + pid: this.proc.pid, + exitCode: this.exitCode, + parentToolCallId: this.parentToolCallId, + }; + } + + private async disposeProcess(): Promise { + try { + await this.proc.dispose(); + } catch { + } finally { + this.release?.(); + this.release = undefined; + } + } +} + +async function waitForStreamDrain(streamDrained: Promise): Promise { + let timeout: ReturnType | undefined; + try { + await Promise.race([ + streamDrained, + new Promise((resolve) => { + timeout = setTimeout(resolve, STREAM_DRAIN_GRACE_MS); + timeout.unref?.(); + }), + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } +} + +async function waitForStreamDrainSettled(streamDrained: Promise): Promise { + try { + await waitForStreamDrain(streamDrained); + } catch { + } +} + +function observeProcessStream( + stream: Readable, + kind: ProcessTaskOutputKind, + sink: AgentTaskSink, + onOutput?: ProcessTaskOutputCallback, +): Promise { + stream.setEncoding('utf8'); + const onData = (chunk: string): void => { + if (chunk.length === 0) return; + sink.appendOutput(chunk); + if (sink.signal.aborted) return; + onOutput?.(kind, chunk); + }; + stream.on('data', onData); + + return new Promise((resolve, reject) => { + let ended = false; + const settle = (callback: () => void): void => { + cleanup(); + callback(); + }; + const done = (): void => { + settle(resolve); + }; + const fail = (error: unknown): void => { + settle(() => reject(error)); + }; + const onEnd = (): void => { + ended = true; + done(); + }; + const onClose = (): void => { + if (ended || sink.signal.aborted) { + done(); + return; + } + + fail(createPrematureCloseError()); + }; + const onError = (error: Error): void => { + if (sink.signal.aborted) { + done(); + } else { + fail(error); + } + }; + const cleanup = (): void => { + stream.removeListener('data', onData); + stream.removeListener('end', onEnd); + stream.removeListener('close', onClose); + stream.removeListener('error', onError); + }; + stream.once('end', onEnd); + stream.once('close', onClose); + stream.once('error', onError); + }); +} + +export interface ProcessTaskResult { + readonly exitCode: number | null; +} + +export function createProcessExecutor( + proc: ProcessHandle, + onOutput?: ProcessTaskOutputCallback, +): (signal: AbortSignal, output: (data: string) => void) => Promise { + return async (signal, output) => { + const forwardOutput = (chunk: string, kind: ProcessTaskOutputKind): void => { + if (chunk.length === 0) return; + output(chunk); + if (signal.aborted) return; + onOutput?.(kind, chunk); + }; + + const streamDrained = Promise.all([ + observeProcessStreamRaw(proc.stdout, 'stdout', signal, forwardOutput), + observeProcessStreamRaw(proc.stderr, 'stderr', signal, forwardOutput), + ]).then(() => undefined); + void streamDrained.catch(() => {}); + + const requestStop = (): void => { + void proc.kill('SIGTERM').catch(() => {}); + }; + if (signal.aborted) { + requestStop(); + } else { + signal.addEventListener('abort', requestStop, { once: true }); + } + + try { + const exitCode = await proc.wait(); + await waitForStreamDrain(streamDrained); + signal.removeEventListener('abort', requestStop); + await disposeProcess(proc); + if (signal.aborted) throw signal.reason; + if (exitCode !== 0) { + const err = new ProcessExitError(exitCode); + throw err; + } + return { exitCode }; + } catch (error: unknown) { + await waitForStreamDrainSettled(streamDrained); + signal.removeEventListener('abort', requestStop); + await disposeProcess(proc); + throw error; + } + }; +} + +export class ProcessExitError extends Error { + constructor(readonly exitCode: number | null) { + super(`Process exited with code ${exitCode}`); + this.name = 'ProcessExitError'; + } +} + +function observeProcessStreamRaw( + stream: Readable, + kind: ProcessTaskOutputKind, + signal: AbortSignal, + onChunk: (chunk: string, kind: ProcessTaskOutputKind) => void, +): Promise { + stream.setEncoding('utf8'); + const onData = (chunk: string): void => { + onChunk(chunk, kind); + }; + stream.on('data', onData); + + return new Promise((resolve, reject) => { + let ended = false; + const cleanup = (): void => { + stream.removeListener('data', onData); + stream.removeListener('end', onEnd); + stream.removeListener('close', onClose); + stream.removeListener('error', onError); + }; + const done = (): void => { cleanup(); resolve(); }; + const fail = (error: unknown): void => { cleanup(); reject(error); }; + const onEnd = (): void => { ended = true; done(); }; + const onClose = (): void => { + if (ended || signal.aborted) { done(); return; } + fail(createPrematureCloseError()); + }; + const onError = (error: Error): void => { + if (signal.aborted) { done(); } else { fail(error); } + }; + stream.once('end', onEnd); + stream.once('close', onClose); + stream.once('error', onError); + }); +} + +async function disposeProcess(proc: ProcessHandle): Promise { + try { await proc.dispose(); } catch { } +} + +function createPrematureCloseError(): Error { + const error = new Error('Premature close') as NodeJS.ErrnoException; + error.code = 'ERR_STREAM_PREMATURE_CLOSE'; + return error; +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/packages/agent-core-v2/src/agent/tools/os/glob/glob.md b/packages/agent-core-v2/src/agent/tools/os/glob/glob.md new file mode 100644 index 0000000000000000000000000000000000000000..6e364bd919c802ba7e778a386c06b5ce4c800971 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/os/glob/glob.md @@ -0,0 +1,18 @@ +Find files by glob pattern, sorted by modification time (most recent first). + +Powered by ripgrep. Respects `.gitignore`, `.ignore`, and `.rgignore` by default — set `include_ignored` to also match ignored files (e.g. build outputs, `node_modules`). Sensitive files (such as `.env`) are always filtered out. Matches are files only — directories themselves are never listed; to find a directory, glob for a file inside it (e.g. `**/fixtures/**`). + +Good patterns: +- `*.ts` — all files matching an extension, at any depth below the search root (a bare pattern without `/` matches recursively) +- `src/*.ts` — files directly inside `src/` (one level, not recursive) +- `src/**/*.ts` — recursive walk with a subdirectory anchor and extension +- `**/*.py` — recursive walk from the search root for an extension +- `*.{ts,tsx}` — brace expansion is supported +- `{src,test}/**/*.ts` — cartesian brace expansion is supported too + +Results default to 100 matching paths. Use `offset` (default 0) and `head_limit` (default 100) to page through results. When more matches are available, the result gives the next offset; keep the other search arguments unchanged. Set `head_limit=0` to remove the match-count limit. Pages still stay within the character retention limit, including notices: when it is reached, only complete paths are returned, with the next offset for continuation. Large pages are saved to a file with a path for Read. + +Each call searches the current filesystem again; pagination is not a snapshot, and file changes can shift results between pages. To collect a large list, use `head_limit=0`, read any saved output, and follow continuation offsets if the character limit is reached. Search timeouts, traversal errors, and output capture limits can still produce partial results; the result reports these limits, and pagination cannot recover paths that were never collected. Narrow the search and retry when it is incomplete. + +Large-directory caveat — avoid recursing into dependency / build output even with an anchor, especially when `include_ignored` is set: +- `node_modules/**/*.js`, `.venv/**/*.py`, `__pycache__/**`, `target/**` can produce thousands of results and waste search time and context. Prefer specific subpaths like `node_modules/react/src/**/*.js` unless you need a complete listing. diff --git a/packages/agent-core-v2/src/agent/tools/os/glob/glob.ts b/packages/agent-core-v2/src/agent/tools/os/glob/glob.ts new file mode 100644 index 0000000000000000000000000000000000000000..a26fc4320e51f9fe4f1c40004483eea232dc35a3 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/os/glob/glob.ts @@ -0,0 +1,55 @@ +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export const GlobInputSchema = z.object({ + pattern: z.string().describe('Glob pattern to match files.'), + head_limit: z + .number() + .int() + .nonnegative() + .optional() + .describe( + 'Maximum number of matching paths to return after offset. Defaults to 100. Pass 0 to remove the match-count limit. The character limit still applies: large pages are saved for Read, and a continuation offset is provided when more paths remain. Search time and output capture limits still apply.', + ), + offset: z + .number() + .int() + .nonnegative() + .optional() + .describe( + 'Number of matching paths to skip. Defaults to 0. Each call searches the current filesystem again; changes can shift results between pages.', + ), + path: z + .string() + .optional() + .describe( + 'Directory to search. Accepts an absolute path, or a path relative to the current working directory. Defaults to the current working directory.', + ), + include_ignored: z + .boolean() + .optional() + .describe( + 'Also match files excluded by ignore files such as `.gitignore`, `.ignore`, and `.rgignore` (for example `node_modules` or build outputs). Sensitive files (such as `.env`) remain filtered out for safety. VCS metadata directories (`.git` and similar) are always skipped, even when this is true. Defaults to false.', + ), + include_dirs: z + .boolean() + .optional() + .describe( + 'Deprecated and ignored. Results are always files-only — directories are never listed. Accepted only so older calls that still pass this flag are not rejected by parameter validation.', + ), +}); + +export type GlobInput = z.infer; + +export const DEFAULT_HEAD_LIMIT = 100; + +export const WINDOWS_PATH_HINT = + '\n\nWindows note: the `path` argument accepts both Windows paths ' + + '(e.g. `C:\\Users\\foo`) and POSIX-style paths (e.g. `/c/Users/foo`). Matched paths are ' + + 'returned in Windows backslash form; convert them to forward slashes before ' + + 'using them in a Bash command.'; + +export interface IGlobTool extends AgentTool { readonly _serviceBrand: undefined } +export const IGlobTool = createDecorator('globTool'); diff --git a/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts b/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts new file mode 100644 index 0000000000000000000000000000000000000000..7be6dda07a62a8c333b20699b06d41b40ae8d7b2 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts @@ -0,0 +1,425 @@ +import { normalize, resolve } from 'pathe'; + +import { ensureRgPath, rgUnavailableMessage, type RgProbe } from '#/os/backends/node-local/tools/rgLocator'; +import { + DEFAULT_TIMEOUT_MS, + MAX_OUTPUT_BYTES, + runRgOnce, + shouldRetryRipgrepEagain, +} from '#/os/backends/node-local/tools/runRg'; +import type { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import type { IHostProcessService } from '#/os/interface/hostProcess'; +import { IAgentRuntimeService, inspectAgentRuntime } from '#/agent/runtimeBinding/agentRuntime'; +import { unwrapErrorCause } from '#/_base/errors/errors'; +import { RuntimeWorkspaceView } from '#/runtime/runtimeWorkspaceView'; +import { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { + DEFAULT_TOOL_RESULT_MAX_RETAINED_CHARS, + ToolAccesses, + type ExecutableToolResult, + type ToolExecution, +} from '#/tool/toolContract'; +import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; +import { + isWithinDirectory, + resolvePathAccessPath, + type PathClass, + isSensitiveFile, + SENSITIVE_DOT_VARIANT_SUFFIXES, + type WorkspaceConfig, +} from '#/tool/path-access'; +import { toInputJsonSchema } from '#/tool/input-schema'; +import { literalRulePattern, matchesGlobRuleSubject } from '#/tool/rule-match'; +import globDescription from './glob.md?raw'; +import { + type GlobInput, + GlobInputSchema, + IGlobTool, + DEFAULT_HEAD_LIMIT, + WINDOWS_PATH_HINT, +} from './glob'; + +const VCS_DIRECTORIES_TO_EXCLUDE = ['.git', '.svn', '.hg', '.bzr', '.jj', '.sl'] as const; + +const SENSITIVE_KEY_BASENAMES = ['id_rsa', 'id_ed25519', 'id_ecdsa'] as const; +const SENSITIVE_GLOBS_TO_EXCLUDE: readonly string[] = [ + '**/.env', + ...SENSITIVE_KEY_BASENAMES.flatMap((name) => [ + `**/${name}`, + `**/${name}[-_]*`, + ...SENSITIVE_DOT_VARIANT_SUFFIXES.map((suffix) => `**/${name}${suffix}`), + ]), + '**/.aws/credentials', + '**/.aws/credentials/**', + '**/.gcp/credentials', + '**/.gcp/credentials/**', +]; + +export class GlobTool implements IGlobTool { + declare readonly _serviceBrand: undefined; + readonly name = 'Glob' as const; + readonly parameters: Record = toInputJsonSchema(GlobInputSchema); + constructor( + @IAgentRuntimeService private readonly runtime: IAgentRuntimeService, + @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, + @ITelemetryService private readonly telemetry: ITelemetryService, + @ISessionSkillCatalog private readonly skillCatalog?: ISessionSkillCatalog, + ) {} + + get description(): string { + return inspectAgentRuntime(this.runtime).environment.pathClass === 'win32' + ? globDescription + WINDOWS_PATH_HINT + : globDescription; + } + + private workspaceConfig(view: RuntimeWorkspaceView): WorkspaceConfig { + return { workspaceDir: view.workDir, additionalDirs: view.additionalDirs }; + } + + resolveExecution(args: GlobInput): ToolExecution { + const inspected = inspectAgentRuntime(this.runtime); + const view = new RuntimeWorkspaceView(inspected, { + workDir: this.workspaceCtx.workDir, + additionalDirs: [ + ...this.workspaceCtx.additionalDirs, + ...(this.skillCatalog?.catalog.getSkillRoots() ?? []), + ], + }); + const env = { _serviceBrand: undefined, ...inspected.environment, ready: Promise.resolve() }; + const workspace = this.workspaceConfig(view); + let path: string | undefined; + if (args.path !== undefined) { + path = resolvePathAccessPath(args.path, { + env, + workspace, + operation: 'search', + policy: { guardMode: 'absolute-outside-allowed', checkSensitive: false }, + }); + } + const searchRoots = [path ?? workspace.workspaceDir]; + + const detailParts: string[] = [`pattern: ${args.pattern}`]; + if (args.path !== undefined) { + detailParts.push(`path: ${args.path}`); + } + if (args.include_ignored === true) { + detailParts.push('include_ignored: true'); + } + + return { + accesses: ToolAccesses.searchTree(searchRoots[0]!), + description: `Searching ${args.pattern}`, + display: { + kind: 'file_io', + operation: 'glob', + path: searchRoots[0]!, + detail: detailParts.join(', '), + }, + approvalRule: literalRulePattern(this.name, args.pattern), + matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.pattern), + execute: async ({ signal }) => { + const lease = this.runtime.acquire(['fs', 'process']); + try { + if (lease.runtime.identity.generation !== inspected.identity.generation) { + return { isError: true, output: 'Runtime changed before execution. Retry the tool call.' }; + } + return await this.execution( + lease.runtime.fs!, + lease.runtime.process!, + env, + workspace, + args, + signal, + searchRoots, + ); + } finally { + lease.dispose(); + } + }, + }; + } + + private async execution( + fs: IHostFileSystem, + processService: IHostProcessService, + env: IHostEnvironment, + workspace: WorkspaceConfig, + args: GlobInput, + signal: AbortSignal, + searchRoots: readonly string[], + ): Promise { + const searchRoot = searchRoots[0] ?? workspace.workspaceDir; + + try { + const st = await fs.stat(searchRoot); + if (!st.isDirectory) { + return { isError: true, output: `${searchRoot} is not a directory` }; + } + } catch (error) { + if (errorCode(error) === 'ENOENT') { + return { isError: true, output: `${searchRoot} does not exist` }; + } + return { isError: true, output: error instanceof Error ? error.message : String(error) }; + } + + if (signal.aborted) { + return { isError: true, output: 'Glob aborted' }; + } + + let rgPath: string; + try { + const resolution = await ensureRgPath(createRgProbe(processService), { + signal, + allowCachedFallback: true, + }); + rgPath = resolution.path; + if (resolution.source !== 'system-path') { + this.telemetry.track2('glob_tool_rg_fallback', { + source: resolution.source, + outcome: 'resolved', + }); + } + } catch (error) { + if (signal.aborted) { + return { isError: true, output: 'Glob aborted' }; + } + this.telemetry.track2('glob_tool_rg_fallback', { outcome: 'failed' }); + return { isError: true, output: rgUnavailableMessage(error) }; + } + + let run; + try { + run = await runRgOnce(processService, buildRgArgs(rgPath, args), signal, { cwd: searchRoot }); + } catch (error) { + return { isError: true, output: formatSpawnError(error) }; + } + if (run.kind === 'aborted') { + return { isError: true, output: 'Glob aborted' }; + } + + if (shouldRetryRipgrepEagain(run)) { + try { + run = await runRgOnce(processService, buildRgArgs(rgPath, args, true), signal, { cwd: searchRoot }); + } catch (error) { + return { isError: true, output: formatSpawnError(error) }; + } + if (run.kind === 'aborted') { + return { isError: true, output: 'Glob aborted' }; + } + } + + const { exitCode, stdoutText, stderrText, bufferTruncated, timedOut } = run; + + let traversalWarning: string | undefined; + if (exitCode !== 0 && exitCode !== 1 && !timedOut) { + const rawPathsBeforeError = splitCompletePaths(stdoutText, true); + if (rawPathsBeforeError.length === 0) { + return { isError: true, output: formatGlobError(searchRoot, stderrText) }; + } + traversalWarning = formatGlobWarning(stderrText); + } + if (signal.aborted) { + return { isError: true, output: 'Glob aborted' }; + } + + const rawPaths = splitCompletePaths(stdoutText, bufferTruncated || timedOut).map((p) => + resolve(searchRoot, p), + ); + + const kept: string[] = []; + let filteredSensitive = 0; + for (const p of rawPaths) { + if (isSensitiveFile(p)) { + filteredSensitive++; + } else { + kept.push(p); + } + } + + const offset = args.offset ?? 0; + const headLimit = args.head_limit ?? DEFAULT_HEAD_LIMIT; + const limited = headLimit === 0 ? kept.slice(offset) : kept.slice(offset, offset + headLimit); + const partial = bufferTruncated || timedOut || traversalWarning !== undefined; + + const pathClass = env.pathClass; + const shouldRelativize = isWithinDirectory(searchRoot, workspace.workspaceDir, pathClass); + const candidates = limited.map((p) => + shouldRelativize ? relativizeIfUnder(p, searchRoot, pathClass) : p, + ); + + const warnings: string[] = []; + if (timedOut) { + warnings.push( + `Glob timed out after ${String(DEFAULT_TIMEOUT_MS / 1000)}s; partial results returned.`, + ); + } + if (bufferTruncated) { + warnings.push( + `[stdout truncated at ${String(MAX_OUTPUT_BYTES)} bytes; results may be incomplete — use a more specific pattern]`, + ); + } + if (traversalWarning !== undefined) { + warnings.push(traversalWarning); + } + const pageNotices = (count: number, characterLimited: boolean) => { + const lines = [...warnings]; + const footer: string[] = []; + const truncated = characterLimited || offset + count < kept.length; + if (count === 0) { + if (kept.length > 0) { + const resultSet = partial ? 'collected partial result set' : 'current result set'; + lines.push( + `No more matches at offset=${String(offset)} in the ${resultSet} (${String(kept.length)} matches).`, + ); + } else if (partial) { + lines.push('No matches collected; search incomplete.'); + } else if (filteredSensitive > 0) { + lines.push( + `No non-sensitive matches found (${String(filteredSensitive)} sensitive file(s) filtered).`, + ); + } else { + lines.push('No matches found'); + } + } else if (truncated || offset > 0 || partial) { + const total = partial + ? `${String(kept.length)} collected matches (partial result set)` + : String(kept.length); + lines.push(`Showing matches ${String(offset + 1)}–${String(offset + count)} of ${total}.`); + } + if (characterLimited) lines.push('Character limit reached; only complete paths are returned.'); + if (truncated) { + lines.push( + `Continue with the same search arguments and offset=${String(offset + count)}.`, + ); + if (!characterLimited) lines.push('To remove the match-count limit, omit offset and use head_limit=0.'); + } + if (filteredSensitive > 0 && (kept.length > 0 || partial)) { + footer.push(`Filtered ${String(filteredSensitive)} sensitive file(s).`); + } + if (!truncated && !partial && offset === 0 && headLimit > 0 && count === headLimit) { + footer.push(`Found ${String(count)} matches`); + } + return { lines, footer }; + }; + const noticeChars = Math.max(...[false, true].map((characterLimited) => { + const { lines, footer } = pageNotices(candidates.length, characterLimited); + return [...lines, ...footer].join('\n').length + 2; + })); + let remaining = DEFAULT_TOOL_RESULT_MAX_RETAINED_CHARS - noticeChars; + const displayLines: string[] = []; + for (const path of candidates) { + if (path.length + 1 > remaining) break; + displayLines.push(path); + remaining -= path.length + 1; + } + if (candidates.length > 0 && displayLines.length === 0) { + return { + isError: true, + output: 'Glob cannot fit a complete path and its diagnostics within the output limit. Narrow the search path or pattern.', + }; + } + const notices = pageNotices(displayLines.length, displayLines.length < candidates.length); + return { output: [...notices.lines, ...displayLines, ...notices.footer].join('\n') }; + } +} + +registerAgentToolService(IGlobTool, GlobTool, { + name: 'Glob', + domain: 'os/backends', + requiredRuntimeCapabilities: ['fs', 'process'], +}); + +function createRgProbe(processService: IHostProcessService): RgProbe { + return { + exec: async (args) => { + const [command, ...rest] = args; + if (command === undefined) return { exitCode: -1 }; + const proc = await processService.spawn(command, rest); + try { + proc.stdin.end(); + } catch { + } + proc.stdout.resume(); + proc.stderr.resume(); + const exitCode = await proc.wait(); + try { + void proc.dispose(); + } catch { + } + return { exitCode }; + }, + }; +} + +function buildRgArgs(rgPath: string, args: GlobInput, singleThreaded = false): string[] { + const cmd: string[] = [rgPath]; + if (singleThreaded) cmd.push('-j', '1'); + cmd.push('--files', '--hidden', '--sortr=modified'); + for (const dir of VCS_DIRECTORIES_TO_EXCLUDE) { + cmd.push('--glob', `!${dir}`); + } + cmd.push('--glob', args.pattern); + for (const glob of SENSITIVE_GLOBS_TO_EXCLUDE) { + cmd.push('--glob', `!${glob}`); + } + if (args.include_ignored) cmd.push('--no-ignore'); + cmd.push('.'); + return cmd; +} + +function formatGlobError(searchRoot: string, stderr: string): string { + const trimmed = stderr.trim(); + if (/no such file or directory/i.test(trimmed)) { + return `${searchRoot} does not exist`; + } + return trimmed.length > 0 ? `Glob failed: ${trimmed}` : 'Glob failed'; +} + +function formatGlobWarning(stderr: string): string { + const trimmed = stderr.trim(); + return trimmed.length > 0 + ? `Glob completed with warnings; some directories could not be read: ${trimmed}` + : 'Glob completed with warnings; some directories could not be read.'; +} + +function formatSpawnError(error: unknown): string { + return errorCode(error) === 'ENOENT' + ? rgUnavailableMessage(error) + : error instanceof Error + ? error.message + : String(error); +} + +function errorCode(error: unknown): string | undefined { + const unwrapped = unwrapErrorCause(error); + if (unwrapped !== null && typeof unwrapped === 'object' && 'code' in unwrapped) { + const code = (unwrapped as { code?: unknown }).code; + return typeof code === 'string' ? code : undefined; + } + return undefined; +} + +export function splitCompletePaths(stdoutText: string, truncatedOutput: boolean): string[] { + let text = stdoutText; + if (truncatedOutput && !text.endsWith('\n')) { + const lastNewline = text.lastIndexOf('\n'); + text = lastNewline >= 0 ? text.slice(0, lastNewline + 1) : ''; + } + return text.split('\n').filter((p) => p.length > 0); +} + +function relativizeIfUnder(candidate: string, base: string, pathClass: PathClass): string { + const normCandidate = normalize(candidate); + const normBase = normalize(base); + const comparableCandidate = pathClass === 'win32' ? normCandidate.toLowerCase() : normCandidate; + const comparableBase = pathClass === 'win32' ? normBase.toLowerCase() : normBase; + if (comparableCandidate === comparableBase) return '.'; + const prefix = comparableBase.endsWith('/') ? comparableBase : comparableBase + '/'; + if (comparableCandidate.startsWith(prefix)) { + return normCandidate.slice(prefix.length); + } + return normCandidate; +} diff --git a/packages/agent-core-v2/src/agent/tools/os/grep/grep.md b/packages/agent-core-v2/src/agent/tools/os/grep/grep.md new file mode 100644 index 0000000000000000000000000000000000000000..c0d2776b37c0da6dee4543c18e691f1865508cb8 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/os/grep/grep.md @@ -0,0 +1,9 @@ +Search file contents using regular expressions (powered by ripgrep). + +Use Grep when the task is to find unknown content or unknown file locations. Do not use shell `grep` or `rg` directly; this tool applies workspace path policy, output limits, and sensitive-file filtering. +ALWAYS use Grep tool instead of running `grep` or `rg` from a shell — direct shell calls bypass workspace policy, output limits, and sensitive-file filtering. +If you already know a concrete file path and need to inspect its contents, use Read directly instead. + +Write patterns in ripgrep regex syntax, which differs from POSIX `grep` syntax. For example, braces are special, so escape them as `\{` to match a literal `{`. + +Hidden files (dotfiles such as `.gitlab-ci.yml` or `.eslintrc.json`) are searched by default. To also search files excluded by `.gitignore` (such as `node_modules` or build outputs), set `include_ignored` to `true`. Sensitive files (such as `.env`) are always skipped for safety, even when `include_ignored` is `true`. diff --git a/packages/agent-core-v2/src/agent/tools/os/grep/grep.ts b/packages/agent-core-v2/src/agent/tools/os/grep/grep.ts new file mode 100644 index 0000000000000000000000000000000000000000..3941bb12d102f6d589302b755862765f0c011701 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/os/grep/grep.ts @@ -0,0 +1,107 @@ +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export const GrepInputSchema = z.object({ + pattern: z.string().describe('Regular expression to search for.'), + path: z + .string() + .optional() + .describe( + 'File or directory to search. Accepts an absolute path, or a path relative to the current working directory. Omit to search the current working directory. Use Read instead when you already know a concrete file path and need its contents.', + ), + glob: z + .string() + .optional() + .describe( + "Optional glob filter for which files to search, e.g. `*.ts`. Matched against each file's full absolute path, so a path-anchored pattern like `src/**/*.ts` silently matches nothing — use a basename pattern (`*.ts`), or anchor with `**/` (`**/src/**/*.ts`). To scope the search to a directory, use `path` instead.", + ), + type: z + .string() + .optional() + .describe( + 'Optional ripgrep file type filter, such as ts or py. Prefer this over `glob` when filtering by language or file kind: it is more efficient and less error-prone than an equivalent glob pattern.', + ), + output_mode: z + .enum(['content', 'files_with_matches', 'count_matches']) + .optional() + .describe( + 'Shape of the result. `content` shows matching lines (honors `-A`, `-B`, `-C`, `-n`, and `head_limit`); `files_with_matches` shows only the paths of files that contain a match, most-recently-modified first (honors `head_limit`); `count_matches` shows per-file match counts as `path:count` lines, preceded by an aggregate total line. Defaults to `files_with_matches`.', + ), + '-i': z.boolean().optional().describe('Perform a case-insensitive search. Defaults to false.'), + '-n': z + .boolean() + .optional() + .describe( + 'Prefix each matching line with its line number. Applies only when `output_mode` is `content`. Defaults to true.', + ), + '-A': z + .number() + .int() + .nonnegative() + .optional() + .describe( + 'Number of lines to show after each match. Applies only when `output_mode` is `content`.', + ), + '-B': z + .number() + .int() + .nonnegative() + .optional() + .describe( + 'Number of lines to show before each match. Applies only when `output_mode` is `content`.', + ), + '-C': z + .number() + .int() + .nonnegative() + .optional() + .describe( + 'Number of lines to show before and after each match. Applies only when `output_mode` is `content`; takes precedence over `-A` and `-B`.', + ), + head_limit: z + .number() + .int() + .nonnegative() + .optional() + .describe( + 'Limit output to the first N lines/entries after offset. Defaults to 250. Pass 0 for unlimited.', + ), + offset: z + .number() + .int() + .nonnegative() + .optional() + .describe( + 'Number of leading lines/entries to skip before applying `head_limit`. Use it together with `head_limit` to page through large result sets. Defaults to 0.', + ), + multiline: z + .boolean() + .optional() + .describe( + 'Enable multiline matching, where the pattern can span line boundaries and `.` also matches newlines. Defaults to false.', + ), + include_ignored: z + .boolean() + .optional() + .describe( + 'Also search files excluded by ignore files such as `.gitignore`, `.ignore`, and `.rgignore` (for example `node_modules` or build outputs). Sensitive files (such as `.env`) remain filtered out for safety. VCS metadata directories (`.git` and similar) are always skipped, even when this is true. Defaults to false.', + ), +}); + +export const GrepOutputSchema = z.object({ + mode: z.enum(['content', 'files_with_matches', 'count_matches']), + numFiles: z.number().int().nonnegative(), + filenames: z.array(z.string()), + content: z.string().optional(), + numLines: z.number().int().nonnegative().optional(), + numMatches: z.number().int().nonnegative().optional(), + appliedLimit: z.number().int().nonnegative().optional(), +}); + +export type GrepInput = z.infer; +export type GrepOutput = z.infer; + +export interface IGrepTool extends AgentTool { readonly _serviceBrand: undefined } +export const IGrepTool = createDecorator('grepTool'); diff --git a/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts b/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts new file mode 100644 index 0000000000000000000000000000000000000000..174817c6e278d6e1b332d508d40c8929a90d0a36 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts @@ -0,0 +1,748 @@ +import { normalize } from 'pathe'; + +import { ToolOutputAccumulator } from '#/tool/output-accumulator'; +import { + ToolAccesses, + type ExecutableToolResult, + type ToolExecution, +} from '#/tool/toolContract'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; +import type { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import type { IHostProcessService } from '#/os/interface/hostProcess'; +import { IAgentRuntimeService, inspectAgentRuntime } from '#/agent/runtimeBinding/agentRuntime'; +import { RuntimeWorkspaceView } from '#/runtime/runtimeWorkspaceView'; +import { unwrapErrorCause } from '#/_base/errors/errors'; +import { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { + resolvePathAccessPath, + type PathClass, + isSensitiveFile, + SENSITIVE_DOT_VARIANT_SUFFIXES, + type WorkspaceConfig, +} from '#/tool/path-access'; +import { toInputJsonSchema } from '#/tool/input-schema'; +import { literalRulePattern, matchesGlobRuleSubject } from '#/tool/rule-match'; +import { + ensureRgPath, + rgUnavailableMessage, + type RgProbe, +} from '#/os/backends/node-local/tools/rgLocator'; +import { + DEFAULT_TIMEOUT_MS, + MAX_OUTPUT_BYTES, + runRgOnce, + shouldRetryRipgrepEagain, + type RunRgResult, +} from '#/os/backends/node-local/tools/runRg'; +import GREP_DESCRIPTION from './grep.md?raw'; +import { type GrepInput, GrepInputSchema, IGrepTool } from './grep'; + +const RG_MAX_COLUMNS = 500; +const DEFAULT_HEAD_LIMIT = 250; +const MTIME_STAT_CONCURRENCY = 32; + +const VCS_DIRECTORIES_TO_EXCLUDE = ['.git', '.svn', '.hg', '.bzr', '.jj', '.sl'] as const; +const SENSITIVE_KEY_BASENAMES = ['id_rsa', 'id_ed25519', 'id_ecdsa'] as const; +const SENSITIVE_KEY_GLOBS_TO_EXCLUDE = SENSITIVE_KEY_BASENAMES.flatMap((name) => [ + `**/${name}`, + `**/${name}[-_]*`, + ...SENSITIVE_DOT_VARIANT_SUFFIXES.map((suffix) => `**/${name}${suffix}`), +]); +const SENSITIVE_GLOBS_TO_EXCLUDE = [ + '**/.env', + ...SENSITIVE_KEY_GLOBS_TO_EXCLUDE, + '**/.aws/credentials', + '**/.aws/credentials/**', + '**/.gcp/credentials', + '**/.gcp/credentials/**', +] as const; + +const CONTENT_LINE_RE = /^(.*?)([:-])(\d+)\2/; + +export class GrepTool implements IGrepTool { + declare readonly _serviceBrand: undefined; + readonly name = 'Grep' as const; + readonly description = GREP_DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(GrepInputSchema); + constructor( + @IAgentRuntimeService private readonly runtime: IAgentRuntimeService, + @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, + @ITelemetryService private readonly telemetry: ITelemetryService, + @ISessionSkillCatalog private readonly skillCatalog?: ISessionSkillCatalog, + ) {} + + private workspace(view: RuntimeWorkspaceView): WorkspaceConfig { + return { workspaceDir: view.workDir, additionalDirs: view.additionalDirs }; + } + + resolveExecution(args: GrepInput): ToolExecution { + const inspected = inspectAgentRuntime(this.runtime); + const view = new RuntimeWorkspaceView(inspected, { + workDir: this.workspaceCtx.workDir, + additionalDirs: [ + ...this.workspaceCtx.additionalDirs, + ...(this.skillCatalog?.catalog.getSkillRoots() ?? []), + ], + }); + const env = { _serviceBrand: undefined, ...inspected.environment, ready: Promise.resolve() }; + const workspace = this.workspace(view); + let path: string | undefined; + if (args.path !== undefined) { + path = resolvePathAccessPath(args.path, { + env, + workspace, + operation: 'search', + policy: { guardMode: 'absolute-outside-allowed', checkSensitive: false }, + }); + } + const searchPaths = [path ?? workspace.workspaceDir]; + const searchPath = args.path ?? workspace.workspaceDir; + return { + accesses: ToolAccesses.searchTree(searchPaths[0]!), + description: `Searching for '${args.pattern}' in ${searchPath}`, + display: { kind: 'file_io', operation: 'grep', path: searchPaths[0]! }, + approvalRule: literalRulePattern(this.name, args.pattern), + matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.pattern), + execute: async ({ signal }) => { + const lease = this.runtime.acquire(['fs', 'process']); + try { + if (lease.runtime.identity.generation !== inspected.identity.generation) { + return { isError: true, output: 'Runtime changed before execution. Retry the tool call.' }; + } + return await this.execution(lease.runtime.process!, lease.runtime.fs!, env, workspace, args, signal, searchPaths); + } finally { + lease.dispose(); + } + }, + }; + } + + private async execution( + processService: IHostProcessService, + fs: IHostFileSystem, + env: IHostEnvironment, + workspace: WorkspaceConfig, + args: GrepInput, + signal: AbortSignal, + searchPaths: string[], + ): Promise { + if (signal.aborted) { + return { isError: true, output: 'Aborted before search started' }; + } + + const pathClass = env.pathClass; + let rgPath: string; + try { + const resolution = await ensureRgPath(this.createRgProbe(processService), { + signal, + allowCachedFallback: true, + }); + rgPath = resolution.path; + if (resolution.source !== 'system-path') { + this.telemetry.track2('grep_tool_rg_fallback', { + source: resolution.source, + outcome: 'resolved', + }); + } + } catch (error) { + if (signal.aborted) { + return { isError: true, output: 'Grep aborted' }; + } + this.telemetry.track2('grep_tool_rg_fallback', { outcome: 'failed' }); + return { isError: true, output: rgUnavailableMessage(error) }; + } + + let runResult: RunRgResult; + try { + const firstRun = await runRgOnce( + processService, + buildRgArgs(rgPath, args, searchPaths), + signal, + ); + if (firstRun.kind === 'aborted') { + return { isError: true, output: 'Grep aborted' }; + } + runResult = firstRun; + + if (shouldRetryRipgrepEagain(runResult)) { + const retryRun = await runRgOnce( + processService, + buildRgArgs(rgPath, args, searchPaths, true), + signal, + ); + if (retryRun.kind === 'aborted') { + return { isError: true, output: 'Grep aborted' }; + } + runResult = retryRun; + } + } catch (error) { + return { isError: true, output: formatSpawnError(error) }; + } + + const { exitCode, stderrText, bufferTruncated, stderrTruncated, timedOut } = runResult; + let { stdoutText } = runResult; + + if (exitCode !== 0 && exitCode !== 1 && !timedOut) { + return { + isError: true, + output: formatRipgrepError(exitCode, stderrText, stderrTruncated), + }; + } + + const mode = args.output_mode ?? 'files_with_matches'; + if (bufferTruncated || timedOut) { + stdoutText = omitIncompleteTrailingRecord(stdoutText, mode); + } + if (timedOut && stdoutText.trim() === '') { + return { + isError: true, + output: `Grep timed out after ${String(DEFAULT_TIMEOUT_MS / 1000)}s. Try a more specific path or pattern.`, + }; + } + if (signal.aborted) { + return { isError: true, output: 'Grep aborted' }; + } + + const rawLines = parseRipgrepOutput(stdoutText, mode); + + const filteredSensitive = new Set(); + const keptLines = filterSensitiveLines(rawLines, mode, filteredSensitive, pathClass); + let orderedLines: ParsedGrepLine[]; + try { + orderedLines = + mode === 'files_with_matches' && !timedOut + ? await this.sortFilesWithMatchesByMtime(fs, keptLines, signal) + : keptLines; + } catch (error) { + if (error instanceof GrepAbortedError) { + return { isError: true, output: 'Grep aborted' }; + } + throw error; + } + + const offset = args.offset ?? 0; + const headLimit = args.head_limit ?? DEFAULT_HEAD_LIMIT; + const afterOffset = offset > 0 ? orderedLines.slice(offset) : orderedLines; + const limitActive = headLimit > 0; + const limited = limitActive ? afterOffset.slice(0, headLimit) : afterOffset; + const paginationTruncated = limitActive && afterOffset.length > headLimit; + + const headerLines: string[] = []; + const messages: string[] = []; + if (filteredSensitive.size > 0) { + const displayedFilteredPaths = [...filteredSensitive].map((path) => + relativizeIfUnder(path, workspace.workspaceDir, pathClass), + ); + messages.push( + `Filtered ${String(filteredSensitive.size)} sensitive file(s): ${displayedFilteredPaths.join(', ')}`, + ); + } + if (mode === 'count_matches' && orderedLines.length > 0) { + headerLines.push(formatCountSummary(orderedLines, filteredSensitive.size > 0)); + } + if (paginationTruncated) { + const total = afterOffset.length + offset; + const nextOffset = offset + headLimit; + const paginationNotice = + bufferTruncated || timedOut + ? `Results truncated to ${String(headLimit)} lines (total: ${String(total)} of a partial result set). Use offset=${String(nextOffset)} to see more.` + : `Results truncated to ${String(headLimit)} lines (total: ${String(total)}). Use offset=${String(nextOffset)} to see more.`; + if (mode === 'count_matches') { + headerLines.push(paginationNotice); + } else { + messages.push(paginationNotice); + } + } + if (bufferTruncated) { + messages.push( + `[Output truncated at ${String(MAX_OUTPUT_BYTES)} bytes of rg output — the result set is incomplete. Narrow the pattern, path, or glob filters and re-run to recover complete results.]`, + ); + } + if (timedOut) { + messages.push( + `Grep timed out after ${String(DEFAULT_TIMEOUT_MS / 1000)}s; partial results returned. Narrow the path, glob, or pattern and retry for complete results.`, + ); + } + + const contentIncludesLineNumbers = mode === 'content' && args['-n'] !== false; + const displayedLines = limited.map((line) => + formatDisplayLine( + line, + mode, + workspace.workspaceDir, + pathClass, + contentIncludesLineNumbers, + ), + ); + const contentBody = displayedLines.join('\n'); + const visibleBody = + orderedLines.length === 0 && filteredSensitive.size > 0 + ? 'No non-sensitive matches found' + : contentBody; + const emptyResultMessage = + SENSITIVE_GLOBS_TO_EXCLUDE.length > 0 ? 'No non-sensitive matches found' : 'No matches found'; + const body = + visibleBody === '' && headerLines.length === 0 && messages.length === 0 + ? emptyResultMessage + : visibleBody; + const combined = [...headerLines, body, ...messages].filter((part) => part !== '').join('\n'); + + const builder = new ToolOutputAccumulator(); + builder.write(combined); + return builder.ok(); + } + + private createRgProbe(processService: IHostProcessService): RgProbe { + return { + exec: async (args) => { + const [command, ...rest] = args; + if (command === undefined) return { exitCode: -1 }; + const proc = await processService.spawn(command, rest); + try { + proc.stdin.end(); + } catch { + } + proc.stdout.resume(); + proc.stderr.resume(); + const exitCode = await proc.wait(); + try { + void proc.dispose(); + } catch { + } + return { exitCode }; + }, + }; + } + + private async sortFilesWithMatchesByMtime( + fs: IHostFileSystem, + lines: readonly ParsedGrepLine[], + signal: AbortSignal, + ): Promise { + const entries = await mapWithConcurrency( + lines, + MTIME_STAT_CONCURRENCY, + signal, + async (line, index) => { + const path = + line.kind === 'record' ? line.filePath : line.kind === 'legacy' ? line.text : undefined; + let mtime = 0; + if (path !== undefined) { + try { + const mtimeMs = (await fs.stat(path)).mtimeMs ?? 0; + mtime = Math.trunc(mtimeMs / 1000); + } catch { + } + } + return { line, mtime, index }; + }, + ); + entries.sort((a, b) => b.mtime - a.mtime || a.index - b.index); + return entries.map((entry) => entry.line); + } +} + +registerAgentToolService(IGrepTool, GrepTool, { + name: 'Grep', + domain: 'os/backends', + requiredRuntimeCapabilities: ['fs', 'process'], +}); + +function formatSpawnError(error: unknown): string { + return errorCode(error) === 'ENOENT' + ? rgUnavailableMessage(error) + : error instanceof Error + ? error.message + : String(error); +} + +function errorCode(error: unknown): string | undefined { + const unwrapped = unwrapErrorCause(error); + if (unwrapped !== null && typeof unwrapped === 'object' && 'code' in unwrapped) { + const code = (unwrapped as { code?: unknown }).code; + return typeof code === 'string' ? code : undefined; + } + return undefined; +} + +type GrepMode = 'content' | 'files_with_matches' | 'count_matches'; + +type ParsedGrepLine = + | { + readonly kind: 'record'; + readonly filePath: string; + readonly payload: string; + } + | { + readonly kind: 'separator'; + } + | { + readonly kind: 'legacy'; + readonly text: string; + }; + +class GrepAbortedError extends Error { + constructor() { + super('Grep aborted'); + this.name = 'GrepAbortedError'; + } +} + +async function mapWithConcurrency( + items: readonly T[], + concurrency: number, + signal: AbortSignal, + mapper: (item: T, index: number) => Promise, +): Promise { + if (signal.aborted) throw new GrepAbortedError(); + if (items.length === 0) return []; + + const results: U[] = []; + results.length = items.length; + let nextIndex = 0; + const workerCount = Math.min(Math.max(1, concurrency), items.length); + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (true) { + if (signal.aborted) return; + const index = nextIndex; + nextIndex += 1; + if (index >= items.length) return; + results[index] = await mapper(items[index] as T, index); + } + }), + ); + if (signal.aborted) throw new GrepAbortedError(); + return results; +} + +function buildRgArgs( + rgPath: string, + args: GrepInput, + searchPaths: readonly string[], + singleThreaded = false, +): string[] { + const cmd: string[] = [rgPath]; + if (singleThreaded) cmd.push('-j', '1'); + cmd.push('--hidden'); + const mode = args.output_mode ?? 'files_with_matches'; + if (mode !== 'content') { + cmd.push('--max-columns', String(RG_MAX_COLUMNS)); + } + cmd.push('--null'); + for (const dir of VCS_DIRECTORIES_TO_EXCLUDE) { + cmd.push('--glob', `!${dir}`); + } + + if (mode === 'files_with_matches') cmd.push('-l'); + else if (mode === 'count_matches') { + cmd.push('--count-matches', '--with-filename'); + } + + if (args['-i']) cmd.push('-i'); + if (mode === 'content') { + cmd.push('--with-filename'); + if (args['-n'] !== false) { + cmd.push('-n'); + } else { + cmd.push('--field-context-separator', ':'); + } + if (args['-C'] !== undefined) { + cmd.push('-C', String(args['-C'])); + } else { + if (args['-A'] !== undefined) cmd.push('-A', String(args['-A'])); + if (args['-B'] !== undefined) cmd.push('-B', String(args['-B'])); + } + } + if (args.glob !== undefined) cmd.push('--glob', args.glob); + if (args.type !== undefined) cmd.push('--type', args.type); + if (args.multiline) cmd.push('-U', '--multiline-dotall'); + if (args.include_ignored) cmd.push('--no-ignore'); + for (const glob of SENSITIVE_GLOBS_TO_EXCLUDE) { + cmd.push('--glob', `!${glob}`); + } + + cmd.push('--', args.pattern, ...searchPaths); + return cmd; +} + +function splitRgLines(text: string): string[] { + if (text === '') return []; + const lines = text.split('\n'); + while (lines.length > 0 && lines.at(-1) === '') { + lines.pop(); + } + return lines.map((line) => stripTrailingCarriageReturn(line)); +} + +function parseRipgrepOutput(text: string, mode: GrepMode): ParsedGrepLine[] { + if (text === '') return []; + if (!text.includes('\0')) { + return splitRgLines(text).map((line) => + mode === 'content' && line === '--' ? { kind: 'separator' } : { kind: 'legacy', text: line }, + ); + } + + if (mode === 'files_with_matches') { + return text + .split('\0') + .map((filePath) => stripTrailingCarriageReturn(filePath)) + .filter((filePath) => filePath !== '') + .map((filePath) => ({ kind: 'record', filePath, payload: '' })); + } + + const records: ParsedGrepLine[] = []; + let cursor = 0; + while (cursor < text.length) { + if (text[cursor] === '\n') { + cursor += 1; + continue; + } + if (text.startsWith('--\r\n', cursor)) { + records.push({ kind: 'separator' }); + cursor += 4; + continue; + } + if (text.startsWith('--\n', cursor)) { + records.push({ kind: 'separator' }); + cursor += 3; + continue; + } + + const nulIndex = text.indexOf('\0', cursor); + if (nulIndex < 0) { + const tail = stripTrailingCarriageReturn(text.slice(cursor)); + if (tail !== '') records.push({ kind: 'legacy', text: tail }); + break; + } + + const lineEnd = text.indexOf('\n', nulIndex + 1); + const payloadEnd = lineEnd >= 0 ? lineEnd : text.length; + const filePath = text.slice(cursor, nulIndex); + const payload = stripTrailingCarriageReturn(text.slice(nulIndex + 1, payloadEnd)); + records.push({ kind: 'record', filePath, payload }); + cursor = lineEnd >= 0 ? lineEnd + 1 : text.length; + } + return records; +} + +function formatDisplayLine( + line: ParsedGrepLine, + mode: GrepMode, + workspaceDir: string, + pathClass: PathClass, + contentIncludesLineNumbers: boolean, +): string { + if (line.kind === 'separator') return '--'; + if (line.kind === 'record') { + const displayPath = relativizeIfUnder(line.filePath, workspaceDir, pathClass); + if (mode === 'files_with_matches') return displayPath; + if (mode === 'count_matches') return `${displayPath}:${line.payload}`; + const separator = contentIncludesLineNumbers ? contentPayloadPathSeparator(line.payload) : ':'; + return `${displayPath}${separator}${line.payload}`; + } + + const text = line.text; + if (mode === 'files_with_matches') { + return relativizeIfUnder(text, workspaceDir, pathClass); + } + if (mode === 'count_matches') { + const idx = text.lastIndexOf(':'); + if (idx <= 0) return text; + return relativizeIfUnder(text.slice(0, idx), workspaceDir, pathClass) + text.slice(idx); + } + + const filePath = extractContentFilePath(text, pathClass); + if (filePath !== undefined) { + return relativizeIfUnder(filePath, workspaceDir, pathClass) + text.slice(filePath.length); + } + return text; +} + +function relativizeIfUnder(candidate: string, base: string, pathClass: PathClass): string { + const normCandidate = normalize(candidate); + const normBase = normalize(base); + const comparableCandidate = pathClass === 'win32' ? normCandidate.toLowerCase() : normCandidate; + const comparableBase = pathClass === 'win32' ? normBase.toLowerCase() : normBase; + if (comparableCandidate === comparableBase) return '.'; + const prefix = comparableBase.endsWith('/') ? comparableBase : comparableBase + '/'; + if (comparableCandidate.startsWith(prefix)) { + return normCandidate.slice(prefix.length); + } + return normCandidate; +} + +function omitIncompleteTrailingRecord(text: string, mode: GrepMode): string { + if (!text.includes('\0')) return omitIncompleteTrailingLine(text); + if (mode === 'files_with_matches') { + const lastNul = text.lastIndexOf('\0'); + return lastNul >= 0 ? text.slice(0, lastNul + 1) : ''; + } + + let cursor = 0; + let lastCompleteEnd = 0; + while (cursor < text.length) { + if (text[cursor] === '\n') { + cursor += 1; + lastCompleteEnd = cursor; + continue; + } + if (text.startsWith('--\r\n', cursor)) { + cursor += 4; + lastCompleteEnd = cursor; + continue; + } + if (text.startsWith('--\n', cursor)) { + cursor += 3; + lastCompleteEnd = cursor; + continue; + } + + const nulIndex = text.indexOf('\0', cursor); + if (nulIndex < 0) break; + const lineEnd = text.indexOf('\n', nulIndex + 1); + if (lineEnd < 0) break; + cursor = lineEnd + 1; + lastCompleteEnd = cursor; + } + return text.slice(0, lastCompleteEnd); +} + +function omitIncompleteTrailingLine(text: string): string { + const lastNewline = text.lastIndexOf('\n'); + return lastNewline >= 0 ? text.slice(0, lastNewline) : ''; +} + +function formatRipgrepError( + exitCode: number, + stderrText: string, + stderrTruncated: boolean, +): string { + const stderr = stderrText.trim(); + if (stderr.length === 0) { + return `Failed to grep: ripgrep exited with code ${String(exitCode)}`; + } + + const summary = summarizeRipgrepStderr(stderr); + const lines = [`Failed to grep: ${summary}`, '', 'ripgrep stderr:', stderr]; + if (stderrTruncated) { + lines.push(`[stderr truncated at ${String(MAX_OUTPUT_BYTES)} bytes]`); + } + return lines.join('\n'); +} + +function summarizeRipgrepStderr(stderr: string): string { + const lines = splitRgLines(stderr) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + const errorLine = lines.findLast((line) => line.toLowerCase().startsWith('error:')); + return errorLine ?? lines.at(-1) ?? 'ripgrep error'; +} + +function filterSensitiveLines( + lines: readonly ParsedGrepLine[], + mode: GrepMode, + filteredPaths: Set, + pathClass: PathClass, +): ParsedGrepLine[] { + const kept: ParsedGrepLine[] = []; + for (const line of lines) { + if (line.kind === 'separator') { + kept.push(line); + continue; + } + const filePath = parsedFilePath(line, mode, pathClass); + if (filePath !== undefined && isSensitiveFile(filePath)) { + filteredPaths.add(filePath); + continue; + } + kept.push(line); + } + return mode === 'content' ? normalizeContextSeparators(kept) : kept; +} + +function normalizeContextSeparators(lines: readonly ParsedGrepLine[]): ParsedGrepLine[] { + const normalized: ParsedGrepLine[] = []; + for (const line of lines) { + if ( + line.kind === 'separator' && + (normalized.length === 0 || normalized.at(-1)?.kind === 'separator') + ) { + continue; + } + normalized.push(line); + } + while (normalized.length > 0 && normalized.at(-1)?.kind === 'separator') { + normalized.pop(); + } + return normalized; +} + +function parsedFilePath( + line: ParsedGrepLine, + mode: GrepMode, + pathClass: PathClass, +): string | undefined { + if (line.kind === 'record') return normalize(line.filePath); + if (line.kind === 'separator') return undefined; + const text = line.text; + if (mode === 'files_with_matches') return normalize(text); + if (mode === 'count_matches') { + const idx = text.lastIndexOf(':'); + return idx > 0 ? normalize(text.slice(0, idx)) : normalize(text); + } + return extractContentFilePath(text, pathClass); +} + +function extractContentFilePath(line: string, pathClass: PathClass): string | undefined { + const m = CONTENT_LINE_RE.exec(line); + if (m?.[1] !== undefined) return normalize(m[1]); + + const separatorIndex = noLineNumberContentSeparatorIndex(line, pathClass); + return separatorIndex > 0 ? normalize(line.slice(0, separatorIndex)) : undefined; +} + +function noLineNumberContentSeparatorIndex(line: string, pathClass: PathClass): number { + const searchFrom = pathClass === 'win32' && /^[A-Za-z]:/.test(line) ? 2 : 0; + return line.indexOf(':', searchFrom); +} + +function contentPayloadPathSeparator(payload: string): ':' | '-' { + const m = /^(\d+)([:-])/.exec(payload); + return m?.[2] === '-' ? '-' : ':'; +} + +function stripTrailingCarriageReturn(value: string): string { + return value.endsWith('\r') ? value.slice(0, -1) : value; +} + +function formatCountSummary(lines: readonly ParsedGrepLine[], redactedSensitive: boolean): string { + let totalMatches = 0; + let totalFiles = 0; + for (const line of lines) { + const rawCount = + line.kind === 'record' + ? line.payload + : line.kind === 'legacy' + ? countPayloadFromLegacyLine(line.text) + : undefined; + if (rawCount === undefined) continue; + const count = Number(rawCount); + if (!Number.isSafeInteger(count) || count < 0) continue; + totalMatches += count; + totalFiles++; + } + + const occurrenceWord = totalMatches === 1 ? 'occurrence' : 'occurrences'; + const fileWord = totalFiles === 1 ? 'file' : 'files'; + const scope = redactedSensitive ? 'total non-sensitive' : 'total'; + return `Found ${String(totalMatches)} ${scope} ${occurrenceWord} across ${String(totalFiles)} ${fileWord}.`; +} + +function countPayloadFromLegacyLine(line: string): string | undefined { + const idx = line.lastIndexOf(':'); + return idx > 0 ? line.slice(idx + 1) : undefined; +} diff --git a/packages/agent-core-v2/src/agent/tools/os/read/configSection.ts b/packages/agent-core-v2/src/agent/tools/os/read/configSection.ts new file mode 100644 index 0000000000000000000000000000000000000000..d8431d26ef4613c94ee14a2df44bf22bf9a803eb --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/os/read/configSection.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +import { registerConfigSection } from '#/app/config/configSectionContributions'; + +export const READ_SECTION = 'read'; + +export const ReadConfigSchema = z.object({ + defaultMaxChars: z.number().int().positive().optional(), + maxChars: z.number().int().positive().optional(), +}); + +export type ReadConfig = z.infer; + +registerConfigSection(READ_SECTION, ReadConfigSchema, { defaultValue: {} }); diff --git a/packages/agent-core-v2/src/agent/tools/os/read/read.md b/packages/agent-core-v2/src/agent/tools/os/read/read.md new file mode 100644 index 0000000000000000000000000000000000000000..177d26441ce25deb70d02f14dc6efd13a2a9679d --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/os/read/read.md @@ -0,0 +1,23 @@ +Read a text file from the local filesystem. + +The path may be a `kimi-file://` attachment reference. Its bytes come from the current session's storage, independently of the workspace runtime. Next Read keeps the reference so pagination also works after a fork. For a binary attachment, the error includes a server-local path when available; a converter must be able to access that filesystem. ReadMediaFile accepts the same reference for images and videos. + +If the user provides a concrete file path to a text file, call Read directly. Do not `Glob`, `ls`, or otherwise pre-check known text file paths; missing or invalid file paths return errors you can handle. Do not use Read for directories; use `ls` via Bash for a known directory, or Glob when you need files matching a name pattern (Glob lists files only, never directories). Use `Grep` only when the task is to search for unknown content or locations. + +When you need several files, prefer to read them in parallel: emit multiple `Read` calls in a single response instead of reading one file per turn. + +- Relative paths resolve against the working directory; a path outside the working directory must be absolute. +- Returns text within `max_chars`, including line numbers and the status block, preferring complete lines. The configured default is ${DEFAULT_MAX_CHARS} characters; calls can request up to ${MAX_CHARS}. Characters use JavaScript string length, not UTF-8 bytes or tokens. Read results are not spilled or shortened again by the general tool-output limit. +- Omit `n_lines` to read toward the end of the file. There is no fixed line-count cap. When the task requires the full text of a large file, request a larger `max_chars`, up to ${MAX_CHARS}, in the first call. +- Page larger files with `line_offset` (1-based start line) and `n_lines`. If the result is incomplete, copy the `Next Read` arguments in the status block to continue without gaps or overlaps. Do not answer from a partial page when the task requires the remaining content. +- If a single line cannot fit on its own page, Read returns a fragment and reports its column range. Continue on the same line with the supplied `column_offset`; do not insert a newline between fragments of one source line. A partial line still counts toward the remaining `n_lines` until its ending is returned. +- `column_offset` is a zero-based position in the first line's displayed text, excluding its line-number prefix. It is supported only for forward reads. Offsets past the line or inside a Unicode surrogate pair return an error. Continuation refers to the current file contents; start a new read if the file changed. +- Kimi Code agent event logs (`wire.jsonl` under the sessions directory) follow the same character budget; locate a record with Grep, read it with `n_lines=1`, and follow `Next Read` to retrieve every fragment of a long record. +- Sensitive files (`.env` files, credential stores, SSH private keys, and similar secrets) are refused to protect secrets; do not attempt to read them. Templates and public keys are exempt: `.env.example` / `.env.sample` / `.env.template` and public SSH keys such as `id_rsa.pub` read normally. +- UTF-8 text files are read directly. UTF-16 LE/BE text files (with or without a BOM) are detected automatically and checked with strict decoding first. If malformed sequences are found, Read returns readable text with U+FFFD replacements and a lossy-decoding warning on every page; do not treat this view as exact original text. The status block notes the detected encoding, and Edit/Write on such a file still expect UTF-8 — convert its encoding first (e.g. with `iconv`). Other encodings (e.g. GBK), binary files, and files containing NUL bytes are refused. +- Negative `line_offset` reads from the end of the file (for example, -100 reads the last 100 lines). If the requested tail range exceeds the character budget, the newest complete lines in that range are returned first; `Next Read` covers the omitted earlier range. If no complete line fits, Read reports this and supplies forward `Next Read` arguments for the entire unread range. Omit `column_offset` when using a negative `line_offset`. +- Output format: `\t` per line. +- A `...` status block is appended after the file content. It reports the actual returned range, total lines, effective character budget, whether the requested range is complete, and whether EOF was reached. The block is not part of the file itself. +- Pure CRLF files are displayed with LF line endings; `Edit` matches this output and preserves CRLF when writing back. +- Mixed or lone carriage-return line endings are shown as `\r` and require exact `Edit.old_string` escapes. +- After a successful `Edit`/`Write`, do not re-read solely to prove the write landed. When the task depends on an exact file, API, or output shape, inspect the final external contract before finishing. diff --git a/packages/agent-core-v2/src/agent/tools/os/read/read.ts b/packages/agent-core-v2/src/agent/tools/os/read/read.ts new file mode 100644 index 0000000000000000000000000000000000000000..060f82571c88a5cc507b55a65a4afbe9f44d3b13 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/os/read/read.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export const DEFAULT_MAX_CHARS = 100_000; +export const DEFAULT_MAX_CHARS_LIMIT = 500_000; + +export const TRANSCODE_MAX_BYTES: number = 10 * 1024 * 1024; + +const PositiveLineOffsetSchema = z.number().int().min(1); +const TailLineOffsetSchema = z.number().int().negative(); + +export const ReadInputSchema = z.object({ + path: z + .string() + .describe( + 'Path to a text file or a kimi-file:// attachment reference in the current session. Relative filesystem paths resolve against the working directory; a path outside the working directory must be absolute. Directories are not supported; use `ls` via Bash for a known directory, or Glob for pattern search.', + ), + line_offset: z + .union([PositiveLineOffsetSchema, TailLineOffsetSchema]) + .optional() + .describe( + 'The line number to start reading from. Omit to start at line 1. Negative values read from the end of the file (for example, -100 reads the last 100 lines).', + ), + column_offset: z.number().int().nonnegative().optional().describe( + 'Zero-based character offset within the first line of a forward read, excluding its line-number prefix. Uses JavaScript string length in the displayed text. Copy continuation arguments from the previous result to resume a long line.', + ), + n_lines: z + .number() + .int() + .positive() + .optional() + .describe( + 'The number of lines to read. Omit to read toward the end of the file. Results are bounded by max_chars, with continuation arguments when the requested range is incomplete.', + ), + max_chars: z.number().int().positive().optional().describe( + 'Maximum characters in the returned text, including line numbers and status. Omit for the configured default; requests above the configured maximum are capped.', + ), +}); + +export const ReadOutputSchema = z.object({ + content: z.string(), + lineCount: z.number().int().nonnegative(), +}); + +export type ReadInput = z.infer; +export type ReadOutput = z.infer; + +export interface IReadTool extends AgentTool { readonly _serviceBrand: undefined } +export const IReadTool = createDecorator('readTool'); diff --git a/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts b/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f3b98b42822f319dc5533fe305c7fadbbddd0c9 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts @@ -0,0 +1,601 @@ +import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { IAgentRuntimeService, inspectAgentRuntime } from '#/agent/runtimeBinding/agentRuntime'; +import { ISessionMediaStore } from '#/agent/media/sessionMediaStore'; +import { isDaemonFileUrl } from '#/agent/media/mediaRef'; +import { attachmentFileSource, runtimeFileSource, withAttachmentLocation, type FileReadSource } from '#/agent/tools/fileReadSource'; +import { RuntimeWorkspaceView } from '#/runtime/runtimeWorkspaceView'; +import { unwrapErrorCause } from '#/_base/errors/errors'; +import { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { IConfigService } from '#/app/config/config'; +import { renderToolResultForModel } from '#/agent/contextMemory/toolResultRender'; +import { + ToolAccesses, + type ExecutableToolResult, + type ToolExecution, +} from '#/tool/toolContract'; +import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; +import { + resolvePathAccessPath, + type WorkspaceConfig, +} from '#/tool/path-access'; +import { MEDIA_SNIFF_BYTES, detectFileType } from '#/agent/media/file-type'; +import { toInputJsonSchema } from '#/tool/input-schema'; +import { literalRulePattern, matchesGlobRuleSubject, matchesPathRuleSubject } from '#/tool/rule-match'; +import { makeCarriageReturnsVisible, splitLinesKeepingTerminator, type LineEndingStyle } from '#/_base/text/line-endings'; +import { detectTextEncoding, type UtfTextEncoding } from '#/_base/text/encoding'; +import { renderPrompt } from '#/_base/utils/render-prompt'; +import { + DEFAULT_MAX_CHARS, + DEFAULT_MAX_CHARS_LIMIT, + IReadTool, + ReadInputSchema, + TRANSCODE_MAX_BYTES, + type ReadInput, +} from './read'; +import { IAgentToolResultTruncationService } from '#/agent/toolResultTruncation/toolResultTruncation'; +import { READ_SECTION, type ReadConfig } from './configSection'; +import readDescriptionTemplate from './read.md?raw'; + +interface LineEndingFlags { + hasCrLf: boolean; + hasLf: boolean; + hasLoneCr: boolean; +} + +interface ReadLineEntry { + readonly lineNo: number; + readonly rawContent: string; +} + +interface ReadTailEntry extends ReadLineEntry { + readonly minChars: number; +} + +interface ReadRequest { + readonly args: ReadInput; + readonly maxChars: number; + readonly maxCharsLimit: number; + readonly detectedEncoding?: UtfTextEncoding; + readonly lossyDecoding: boolean; + readonly eventLog: boolean; +} + +interface ReadPage { + readonly request: ReadRequest; + readonly renderedLines: readonly string[]; + readonly startLine: number; + readonly rangeStart: number; + readonly rangeEnd: number; + readonly totalLines: number; + readonly fromTail: boolean; + readonly lineEndingStyle: LineEndingStyle; +} + +function stripTrailingLf(line: string): string { + return line.endsWith('\n') ? line.slice(0, -1) : line; +} + +function splitsSurrogatePair(text: string, offset: number): boolean { + const previous = text.charCodeAt(offset - 1); + const next = text.charCodeAt(offset); + return previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff; +} + +function updateLineEndingFlags(flags: LineEndingFlags, text: string): void { + for (let i = 0; i < text.length; i += 1) { + const code = text.codePointAt(i); + if (code === 13) { + if (text.codePointAt(i + 1) === 10) { + flags.hasCrLf = true; + i += 1; + } else { + flags.hasLoneCr = true; + } + } else if (code === 10) { + flags.hasLf = true; + } + } +} + +function lineEndingStyleFromFlags(flags: LineEndingFlags): LineEndingStyle { + if (flags.hasLoneCr || (flags.hasCrLf && flags.hasLf)) return 'mixed'; + if (flags.hasCrLf) return 'crlf'; + return 'lf'; +} + +function renderLine(entry: ReadLineEntry, lineEndingStyle: LineEndingStyle): string { + const modelContent = + lineEndingStyle === 'crlf' && entry.rawContent.endsWith('\r') + ? entry.rawContent.slice(0, -1) + : entry.rawContent; + const renderedContent = + lineEndingStyle === 'mixed' ? makeCarriageReturnsVisible(modelContent) : modelContent; + return `${String(entry.lineNo)}\t${renderedContent}`; +} + +function isFileNotFoundError(error: unknown): boolean { + const unwrapped = unwrapErrorCause(error); + if (typeof unwrapped !== 'object' || unwrapped === null) return false; + const code = (unwrapped as { code?: unknown })['code']; + return code === 'ENOENT' || code === 'ENOTDIR'; +} + +function isTextDecodeError(error: unknown): boolean { + const unwrapped = unwrapErrorCause(error); + if (typeof unwrapped !== 'object' || unwrapped === null) return false; + const code = (unwrapped as { code?: unknown })['code']; + if (code === 'ERR_ENCODING_INVALID_ENCODED_DATA') return true; + if (!(unwrapped instanceof Error)) return false; + return /encoded data was not valid|invalid.*encoding|invalid.*utf-?8/i.test(unwrapped.message); +} + +function containsNulByte(text: string): boolean { + return text.includes('\u0000'); +} + +function encodingDisplayName(encoding: UtfTextEncoding): string { + switch (encoding) { + case 'utf-16le': + return 'UTF-16 LE'; + case 'utf-16be': + return 'UTF-16 BE'; + default: + return 'UTF-8'; + } +} + +async function* decodedLines(lines: readonly string[]): AsyncGenerator { + yield* lines; +} + +function notReadableFileOutput(path: string): string { + return `"${path}" is not readable as UTF-8 text. Only text files can be read.`; +} + +function notUtf8DecodableFileOutput(path: string): string { + return ( + `"${path}" is not valid UTF-8 or UTF-16 text. ` + + 'Only UTF-8 and UTF-16 text files can be read; ' + + 'for other encodings (e.g. GBK), convert the file to UTF-8 first (e.g. with `iconv`).' + ); +} + +export class ReadTool implements IReadTool { + declare readonly _serviceBrand: undefined; + readonly name = 'Read' as const; + get description(): string { + const limits = this.limits(); + return renderPrompt(readDescriptionTemplate, { + DEFAULT_MAX_CHARS: limits.defaultMaxChars, + MAX_CHARS: limits.maxChars, + }); + } + readonly parameters: Record = toInputJsonSchema(ReadInputSchema); + constructor( + @IAgentRuntimeService private readonly runtime: IAgentRuntimeService, + @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, + @ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog, + @IAgentToolResultTruncationService private readonly resultTruncation: IAgentToolResultTruncationService, + @IConfigService private readonly config: IConfigService, + @ISessionMediaStore private readonly attachmentStore?: ISessionMediaStore, + ) {} + + private limits(): { defaultMaxChars: number; maxChars: number } { + const section = this.config.get(READ_SECTION); + const maxChars = section?.maxChars ?? DEFAULT_MAX_CHARS_LIMIT; + return { + defaultMaxChars: Math.min(section?.defaultMaxChars ?? DEFAULT_MAX_CHARS, maxChars), + maxChars, + }; + } + + private workspaceConfig(view: RuntimeWorkspaceView): WorkspaceConfig { + return { workspaceDir: view.workDir, additionalDirs: view.additionalDirs }; + } + + resolveExecution(args: ReadInput): ToolExecution | Promise { + if (args.column_offset !== undefined && (args.line_offset ?? 1) < 0) { + return { isError: true, output: 'column_offset is only supported for forward reads. Use a positive line_offset or the forward Next Read arguments.' }; + } + if (isDaemonFileUrl(args.path)) return this.attachmentExecution(args); + const inspected = inspectAgentRuntime(this.runtime); + const view = new RuntimeWorkspaceView(inspected, { + workDir: this.workspaceCtx.workDir, + additionalDirs: [...this.workspaceCtx.additionalDirs, ...this.skillCatalog.catalog.getSkillRoots()], + }); + const env = { _serviceBrand: undefined, ...inspected.environment, ready: Promise.resolve() }; + const workspace = this.workspaceConfig(view); + const path = resolvePathAccessPath(args.path, { + env, + workspace, + operation: 'read', + }); + return { + accesses: ToolAccesses.readFile(path), + description: `Reading ${args.path}`, + display: { kind: 'file_io', operation: 'read', path }, + approvalRule: literalRulePattern(this.name, path), + matchesRule: (ruleArgs) => + matchesPathRuleSubject(ruleArgs, path, { + cwd: workspace.workspaceDir, + pathClass: env.pathClass, + homeDir: env.homeDir, + }), + execute: async () => { + const lease = this.runtime.acquire(['fs']); + try { + if (lease.runtime.identity.generation !== inspected.identity.generation) { + return { isError: true, output: 'Runtime changed before execution. Retry the tool call.' }; + } + const eventLog = this.resultTruncation.isWireJournalPath(path); + const result = await this.execution(runtimeFileSource(lease.runtime.fs!, path), args, eventLog); + return { ...result, spillExempt: true }; + } finally { + lease.dispose(); + } + }, + }; + } + + private async attachmentExecution(args: ReadInput): Promise { + const source = await attachmentFileSource(args.path, this.attachmentStore); + return { + accesses: ToolAccesses.readFile(source.localPath ?? args.path), + description: `Reading ${args.path}`, + display: { kind: 'file_io', operation: 'read', path: source.localPath ?? args.path }, + approvalRule: literalRulePattern(this.name, args.path), + matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.path), + execute: async () => ({ + ...withAttachmentLocation(await this.execution(source, args, false), source), + spillExempt: true, + }), + }; + } + + private async execution( + source: FileReadSource, + args: ReadInput, + eventLog: boolean, + ): Promise { + try { + let stat: Awaited>; + try { + stat = await source.stat(); + } catch (error) { + if (isFileNotFoundError(error)) { + return { isError: true, output: `"${args.path}" does not exist.` }; + } + throw error; + } + if (!stat.isFile) { + return { isError: true, output: `"${args.path}" is not a file.` }; + } + + const header = await source.readBytes(MEDIA_SNIFF_BYTES); + const fileType = detectFileType(source.name, header); + if (fileType.kind === 'image' || fileType.kind === 'video') { + return { + isError: true, + output: `"${args.path}" is ${fileType.kind === 'image' ? 'an' : 'a'} ${fileType.kind} file. Only text files can be read.`, + }; + } + + const detection = detectTextEncoding(header); + let readLines: () => AsyncIterable; + let detectedEncoding: UtfTextEncoding | undefined; + let lossyDecoding = false; + if (!detection.seemsBinary && detection.encoding !== 'utf-8') { + if (stat.size > TRANSCODE_MAX_BYTES) { + return { + isError: true, + output: + `"${args.path}" is ${encodingDisplayName(detection.encoding)} text but too large to transcode ` + + `(${String(stat.size)} bytes > ${String(TRANSCODE_MAX_BYTES)}). ` + + 'Convert it to UTF-8 first (e.g. with `iconv`).', + }; + } + const bytes = await source.readBytes(); + let decoded: string; + try { + decoded = new TextDecoder(detection.encoding, { fatal: true }).decode(bytes); + } catch (error) { + if (!isTextDecodeError(error)) throw error; + decoded = new TextDecoder(detection.encoding, { fatal: false }).decode(bytes); + lossyDecoding = true; + } + detectedEncoding = detection.encoding; + const decodedContent = splitLinesKeepingTerminator(decoded); + readLines = () => decodedLines(decodedContent); + } else if (fileType.kind === 'unknown') { + return { + isError: true, + output: notReadableFileOutput(args.path), + }; + } else { + readLines = () => source.readLines(); + } + + const limits = this.limits(); + const request: ReadRequest = { + args, + maxChars: Math.min(args.max_chars ?? limits.defaultMaxChars, limits.maxChars), + maxCharsLimit: limits.maxChars, + detectedEncoding, + lossyDecoding, + eventLog, + }; + const lineOffset = args.line_offset ?? 1; + if (lineOffset >= 0) return await this.readForward(readLines(), request); + const rereadsFile = detectedEncoding === undefined && (args.n_lines ?? Infinity) < -lineOffset; + const result = await this.readTail(readLines, request); + if (!result.isError && rereadsFile) { + const currentStat = await source.stat(); + if (!currentStat.isFile || currentStat.size !== stat.size || + currentStat.mtimeMs !== stat.mtimeMs || currentStat.ino !== stat.ino) { + return { isError: true, output: 'File changed while reading its tail. Retry Read with the updated file.' }; + } + } + return result; + } catch (error) { + if (isTextDecodeError(error)) { + return { isError: true, output: notUtf8DecodableFileOutput(args.path) }; + } + return { + isError: true, + output: error instanceof Error ? error.message : String(error), + }; + } + } + + private async readForward( + lines: AsyncIterable, + request: ReadRequest, + ): Promise { + const { args, maxChars } = request; + const lineOffset = args.line_offset ?? 1; + const columnOffset = args.column_offset ?? 0; + const requestedLines = args.n_lines ?? Infinity; + const selectedEntries: ReadLineEntry[] = []; + const flags: LineEndingFlags = { hasCrLf: false, hasLf: false, hasLoneCr: false }; + let currentLineNo = 0; + let collectionClosed = false; + let minimumChars = 0; + + for await (const rawLine of lines) { + if (containsNulByte(rawLine)) { + return { isError: true, output: notReadableFileOutput(args.path) }; + } + currentLineNo += 1; + updateLineEndingFlags(flags, rawLine); + if (collectionClosed) continue; + if (currentLineNo < lineOffset) continue; + if (selectedEntries.length >= requestedLines) { + collectionClosed = true; + continue; + } + const rawContent = stripTrailingLf(rawLine); + const lineChars = String(currentLineNo).length + 1 + Math.max( + 0, + rawContent.length - (rawContent.endsWith('\r') ? 1 : 0) - + (currentLineNo === lineOffset ? columnOffset : 0), + ) + (selectedEntries.length === 0 ? 0 : 1); + if (minimumChars + lineChars > maxChars && selectedEntries.length > 0) { + collectionClosed = true; + continue; + } + selectedEntries.push({ + lineNo: currentLineNo, + rawContent, + }); + minimumChars += lineChars; + if (selectedEntries.length >= requestedLines || minimumChars >= maxChars) { + collectionClosed = true; + } + } + + const lineEndingStyle = lineEndingStyleFromFlags(flags); + const renderedLines = selectedEntries.map((entry) => renderLine(entry, lineEndingStyle)); + const firstLine = renderedLines[0]; + if (columnOffset > 0) { + const prefix = `${String(lineOffset)}\t`; + const text = firstLine?.slice(prefix.length); + if (text === undefined || columnOffset > text.length) { + return { isError: true, output: `column_offset=${String(columnOffset)} is past the end of the starting line ${String(lineOffset)}. Read the line from column 0 to inspect its current contents.` }; + } + if (splitsSurrogatePair(text, columnOffset)) { + return { isError: true, output: `column_offset=${String(columnOffset)} splits a Unicode character in line ${String(lineOffset)}. Use a character boundary or the Next Read arguments.` }; + } + renderedLines[0] = prefix + text.slice(columnOffset); + } + return this.finishPage({ + request, + renderedLines, + startLine: lineOffset, + rangeStart: lineOffset, + rangeEnd: Math.min(currentLineNo, lineOffset + requestedLines - 1), + totalLines: currentLineNo, + fromTail: false, + lineEndingStyle, + }); + } + + private finishPage(page: ReadPage): ExecutableToolResult { + const { args, maxChars, maxCharsLimit, eventLog, detectedEncoding, lossyDecoding } = page.request; + let first = 0; + let end = page.renderedLines.length; + let contentChars = page.renderedLines.reduce((sum, line) => sum + line.length + 1, -1); + const firstColumn = page.fromTail ? 0 : args.column_offset ?? 0; + const firstPrefix = `${String(page.startLine)}\t`; + const firstText = page.renderedLines[0]?.slice(firstPrefix.length) ?? ''; + let fragmentEnd: number | undefined; + + while (true) { + const count = end - first; + const startLine = page.startLine + first; + const endLine = page.startLine + end - 1; + const lineIncomplete = fragmentEnd !== undefined; + const complete = page.rangeStart > page.rangeEnd || + (count > 0 && startLine === page.rangeStart && endLine === page.rangeEnd && !lineIncomplete); + const parts = [ + count > 0 + ? `${String(count)} ${count === 1 ? 'line' : 'lines'} read from file starting from line ${String(startLine)}.` + : 'No lines read from file.', + `Total lines in file: ${String(page.totalLines)}.`, + complete ? 'Requested range complete.' : 'Character limit reached.', + `Effective max_chars: ${String(maxChars)}.`, + ]; + if (!lineIncomplete && ((count > 0 && endLine === page.totalLines) || page.rangeStart > page.totalLines)) { + parts.push('End of file reached.'); + } + if (count > 0 && !page.fromTail && (firstColumn > 0 || fragmentEnd !== undefined)) { + parts.push(`Line ${String(startLine)} fragment: columns [${String(firstColumn)}, ${String(firstColumn + (fragmentEnd ?? firstText.length))}) of ${String(firstColumn + firstText.length)}. ${lineIncomplete ? 'Line continues.' : 'Line complete.'}`); + } + if (args.max_chars !== undefined && args.max_chars > maxCharsLimit) { + parts.push(`Requested max_chars=${String(args.max_chars)} was capped at the configured maximum ${String(maxCharsLimit)}.`); + } + if (!complete && (count > 0 || page.fromTail)) { + const nextStart = page.fromTail ? page.rangeStart : lineIncomplete ? startLine : endLine + 1; + const nextEnd = page.fromTail && count > 0 ? startLine - 1 : page.rangeEnd; + const next = { + path: args.path, + line_offset: nextStart, + column_offset: fragmentEnd !== undefined ? firstColumn + fragmentEnd : undefined, + n_lines: page.fromTail || args.n_lines !== undefined ? nextEnd - nextStart + 1 : undefined, + max_chars: maxChars, + }; + parts.push(`Next Read: ${JSON.stringify(next)}`); + } + if (eventLog) { + parts.push('Kimi Code agent event log: read one record at a time (n_lines=1); increase max_chars for a longer record or extract fields with Bash.'); + } + if (page.lineEndingStyle === 'mixed') { + parts.push('Mixed or lone carriage-return line endings are shown as \\r. Use exact \\r\\n or \\r escapes in Edit.old_string for those lines.'); + } + if (detectedEncoding !== undefined) { + parts.push(`Detected file encoding: ${encodingDisplayName(detectedEncoding)}; content transcoded to UTF-8 for display. Edit and Write expect UTF-8 — convert the file's encoding first (e.g. \`iconv\` via Bash).`); + } + if (lossyDecoding) { + parts.push('Lossy UTF-16 decoding: malformed sequences were replaced with U+FFFD. The decoded text may differ from the original file.'); + } + const note = `${parts.join(' ')}`; + const renderedChars = count === 0 + ? renderToolResultForModel({ output: '', note }).reduce( + (sum, part) => sum + (part.type === 'text' ? part.text.length : 0), + 0, + ) + : contentChars + 1 + note.length; + if (renderedChars <= maxChars && (complete || count > 0)) { + return { + output: fragmentEnd !== undefined + ? firstPrefix + firstText.slice(0, fragmentEnd) + : page.renderedLines.slice(first, end).join('\n'), + note, + truncated: complete ? undefined : true, + }; + } + if (count === 1 && !page.fromTail) { + const previousEnd = fragmentEnd ?? firstText.length; + fragmentEnd = Math.min(previousEnd - 1, previousEnd - (renderedChars - maxChars)); + if (splitsSurrogatePair(firstText, fragmentEnd)) fragmentEnd -= 1; + if (fragmentEnd <= 0) { + return { isError: true, output: `max_chars=${String(maxChars)} is too small for file text and the Read status. Increase max_chars.` }; + } + contentChars = firstPrefix.length + fragmentEnd; + continue; + } + if (count === 0) { + if (!complete) { + const recovery: ExecutableToolResult = { + isError: true, + output: 'No complete line fits. Continue with the forward Next Read.', + note, + truncated: true, + }; + const recoveryChars = renderToolResultForModel(recovery).reduce( + (sum, part) => sum + (part.type === 'text' ? part.text.length : 0), + 0, + ); + if (recoveryChars <= maxChars) return recovery; + } + return { isError: true, output: `max_chars=${String(maxChars)} is too small for the Read status. Increase max_chars.` }; + } + const dropped = page.fromTail ? first++ : --end; + contentChars -= page.renderedLines[dropped]!.length + 1; + } + } + + private async readTail( + readLines: () => AsyncIterable, + request: ReadRequest, + ): Promise { + const { args, maxChars } = request; + const lineOffset = args.line_offset ?? 1; + const requestedLines = args.n_lines ?? Infinity; + const tailCount = -lineOffset; + const singlePass = requestedLines >= tailCount; + let entries: (ReadTailEntry | undefined)[] = []; + let first = 0; + let chars = 0; + const retainLine = (rawLine: string, lineNo: number): void => { + const rawContent = stripTrailingLf(rawLine); + const minChars = String(lineNo).length + 2 + rawContent.length - (rawContent.endsWith('\r') ? 1 : 0); + entries.push({ lineNo, rawContent, minChars }); + chars += minChars; + while (first < entries.length && (chars - 1 > maxChars || entries.length - first > tailCount)) { + chars -= entries[first]!.minChars; + entries[first++] = undefined; + } + if (first > 1024 && first >= entries.length / 2) { + entries = entries.slice(first); + first = 0; + } + }; + const flags: LineEndingFlags = { hasCrLf: false, hasLf: false, hasLoneCr: false }; + let totalLines = 0; + for await (const rawLine of readLines()) { + if (containsNulByte(rawLine)) { + return { isError: true, output: notReadableFileOutput(args.path) }; + } + totalLines += 1; + updateLineEndingFlags(flags, rawLine); + if (singlePass) retainLine(rawLine, totalLines); + } + + const rangeStart = Math.max(1, totalLines + lineOffset + 1); + const rangeEnd = Math.min(totalLines, rangeStart + requestedLines - 1); + const lineEndingStyle = lineEndingStyleFromFlags(flags); + if (!singlePass) { + let currentLine = 0; + for await (const rawLine of readLines()) { + currentLine += 1; + if (currentLine > rangeEnd) break; + if (currentLine < rangeStart) continue; + if (containsNulByte(rawLine)) { + return { isError: true, output: notReadableFileOutput(args.path) }; + } + retainLine(rawLine, currentLine); + } + if (currentLine < rangeEnd || currentLine > totalLines) { + return { isError: true, output: 'File changed while reading its tail. Retry Read with the updated file.' }; + } + } + const selected = entries.slice(first).map((entry) => renderLine(entry!, lineEndingStyle)); + return this.finishPage({ + request, + renderedLines: selected, + startLine: rangeEnd - selected.length + 1, + rangeStart, + rangeEnd, + totalLines, + fromTail: true, + lineEndingStyle, + }); + } + +} + +registerAgentToolService(IReadTool, ReadTool, { + name: 'Read', + domain: 'os/backends', +}); diff --git a/packages/agent-core-v2/src/agent/tools/os/write/write.md b/packages/agent-core-v2/src/agent/tools/os/write/write.md new file mode 100644 index 0000000000000000000000000000000000000000..f950594c099485ceca606b32c1950f7de94bc2a5 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/os/write/write.md @@ -0,0 +1,11 @@ +Create, append to, or replace a file entirely. + +- Missing parent directories are created automatically (like `mkdir(parents=True, exist_ok=True)`). +- Mode defaults to overwrite; append adds content at EOF without adding a newline. +- Write is NOT ALLOWED for incremental changes to existing files, including trivial, one-line, quick, or cosmetic edits. Use Edit instead. +- Use Write only when the file does not exist, you intend a complete replacement, or the new contents have little continuity with the old contents. +- Do not create unsolicited documentation files (`*.md` write-ups, `README`s, summaries) just because a task finished — write one only when the user asks for it, or when a task or project instruction requires it (e.g. the plan-mode plan file, created with Write when plan mode directs you to, or a changeset the repo mandates). +- Read before overwriting an existing file. +- Write ignores the Read/Edit line-number view. NEVER include line prefixes. +- Write outputs content literally, including supplied line endings: \n stays LF, \r\n stays CRLF. +- For new content too large for one call, overwrite the first chunk, then append subsequent chunks. Never chunk Write to modify an existing file. diff --git a/packages/agent-core-v2/src/agent/tools/os/write/write.ts b/packages/agent-core-v2/src/agent/tools/os/write/write.ts new file mode 100644 index 0000000000000000000000000000000000000000..6c20e05895d44178f0ac477b885c7b92deb62dc9 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/os/write/write.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export const WriteInputSchema = z.object({ + path: z + .string() + .describe( + 'Path to the file to create, append to, or completely overwrite. Relative paths resolve against the working directory; a path outside the working directory must be absolute. Missing parent directories are created automatically.', + ), + content: z + .string() + .describe( + 'Raw full file content to write exactly as provided. This does not use the Read/Edit text view.', + ), + mode: z + .enum(['overwrite', 'append']) + .optional() + .describe( + 'Write mode. Defaults to overwrite. append adds content to the end exactly as provided and does not add a newline.', + ), +}); + +export const WriteOutputSchema = z.object({ + bytesWritten: z.number().int().nonnegative(), +}); + +export type WriteInput = z.infer; +export type WriteOutput = z.infer; + +export interface IWriteTool extends AgentTool { readonly _serviceBrand: undefined } +export const IWriteTool = createDecorator('writeTool'); diff --git a/packages/agent-core-v2/src/agent/tools/os/write/writeTool.ts b/packages/agent-core-v2/src/agent/tools/os/write/writeTool.ts new file mode 100644 index 0000000000000000000000000000000000000000..e9cc37ddb3f477679e93e1b56564cdddd99d6855 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/os/write/writeTool.ts @@ -0,0 +1,140 @@ +import { dirname } from 'pathe'; + +import type { HostFileStat, IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { IAgentRuntimeService, inspectAgentRuntime } from '#/agent/runtimeBinding/agentRuntime'; +import { RuntimeWorkspaceView } from '#/runtime/runtimeWorkspaceView'; +import { unwrapErrorCause } from '#/_base/errors/errors'; +import { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { + ToolAccesses, + type ExecutableToolResult, + type ToolExecution, +} from '#/tool/toolContract'; +import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; +import { + resolvePathAccessPath, + type WorkspaceConfig, +} from '#/tool/path-access'; +import { toInputJsonSchema } from '#/tool/input-schema'; +import { literalRulePattern, matchesPathRuleSubject } from '#/tool/rule-match'; +import { IWriteTool, WriteInputSchema, type WriteInput } from './write'; +import WRITE_DESCRIPTION from './write.md?raw'; + +export class WriteTool implements IWriteTool { + declare readonly _serviceBrand: undefined; + readonly name = 'Write' as const; + readonly description = WRITE_DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(WriteInputSchema); + + constructor( + @IAgentRuntimeService private readonly runtime: IAgentRuntimeService, + @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, + @ISessionSkillCatalog private readonly skillCatalog?: ISessionSkillCatalog, + ) {} + + private workspaceConfig(view: RuntimeWorkspaceView): WorkspaceConfig { + return { workspaceDir: view.workDir, additionalDirs: view.additionalDirs }; + } + + resolveExecution(args: WriteInput): ToolExecution { + const inspected = inspectAgentRuntime(this.runtime); + const view = new RuntimeWorkspaceView(inspected, { + workDir: this.workspaceCtx.workDir, + additionalDirs: [ + ...this.workspaceCtx.additionalDirs, + ...(this.skillCatalog?.catalog.getSkillRoots() ?? []), + ], + }); + const env = { _serviceBrand: undefined, ...inspected.environment, ready: Promise.resolve() }; + const workspace = this.workspaceConfig(view); + const path = resolvePathAccessPath(args.path, { + env, + workspace, + operation: 'write', + }); + return { + accesses: ToolAccesses.writeFile(path), + description: `Writing ${args.path}`, + display: { kind: 'file_io', operation: 'write', path, content: args.content }, + approvalRule: literalRulePattern(this.name, path), + matchesRule: (ruleArgs) => + matchesPathRuleSubject(ruleArgs, path, { + cwd: workspace.workspaceDir, + pathClass: env.pathClass, + homeDir: env.homeDir, + }), + execute: async () => { + const lease = this.runtime.acquire(['fs']); + try { + if (lease.runtime.identity.generation !== inspected.identity.generation) { + return { isError: true, output: 'Runtime changed before execution. Retry the tool call.' }; + } + return await this.execution(lease.runtime.fs!, args, path); + } finally { + lease.dispose(); + } + }, + }; + } + + private async execution(fs: IHostFileSystem, args: WriteInput, safePath: string): Promise { + const parentError = await this.ensureParentDirectory(fs, safePath); + if (parentError !== undefined) { + return { isError: true, output: parentError }; + } + + try { + const mode = args.mode ?? 'overwrite'; + if (mode === 'append') { + await fs.appendText(safePath, args.content); + } else { + await fs.writeText(safePath, args.content); + } + const bytesWritten = Buffer.byteLength(args.content, 'utf8'); + return { + output: `${mode === 'append' ? 'Appended' : 'Wrote'} ${String(bytesWritten)} bytes to ${args.path}`, + }; + } catch (error) { + const code = (unwrapErrorCause(error) as { code?: unknown } | null)?.code; + if (code === 'ENOENT') { + return { + isError: true, + output: `Failed to write ${args.path}: parent directory does not exist.`, + }; + } + return { + isError: true, + output: error instanceof Error ? error.message : String(error), + }; + } + } + + private async ensureParentDirectory(fs: IHostFileSystem, safePath: string): Promise { + const parent = dirname(safePath); + let stat: HostFileStat; + try { + stat = await fs.stat(parent); + } catch (error) { + if ((unwrapErrorCause(error) as { code?: unknown } | null)?.code === 'ENOENT') { + try { + await fs.mkdir(parent, { recursive: true }); + return undefined; + } catch (mkdirError) { + return mkdirError instanceof Error ? mkdirError.message : String(mkdirError); + } + } + return undefined; + } + if (!stat.isDirectory) { + return `Parent path is not a directory: ${parent}.`; + } + return undefined; + } +} + +registerAgentToolService(IWriteTool, WriteTool, { + name: 'Write', + domain: 'os/backends', + requiredRuntimeCapabilities: ['fs'], +}); diff --git a/packages/agent-core-v2/src/agent/tools/read-media-file/read-media-file.ts b/packages/agent-core-v2/src/agent/tools/read-media-file/read-media-file.ts new file mode 100644 index 0000000000000000000000000000000000000000..f40fc0716c27b6c33e070262a90cda10fda45c99 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/read-media-file/read-media-file.ts @@ -0,0 +1,47 @@ +import { z } from 'zod'; + +import type { VideoURLPart } from '#human/llm/message'; +import type { VideoUploadInput as ProviderVideoUploadInput } from '#human/llm/media/upload'; + +export const MAX_MEDIA_MEGABYTES = 100; +export const MAX_MEDIA_BYTES = MAX_MEDIA_MEGABYTES * 1024 * 1024; + +export type VideoUploadInput = ProviderVideoUploadInput; + +export type VideoUploader = ( + input: VideoUploadInput, + options?: { readonly signal?: AbortSignal }, +) => Promise; + +export const ReadMediaFileInputSchema = z.object({ + path: z + .string() + .describe( + 'Path to an image or video file, or a kimi-file:// attachment reference in the current session. Relative filesystem paths resolve against the working directory; ' + + 'a path outside the working directory must be absolute. ' + + 'Directories and text files are not supported.', + ), + region: z + .object({ + x: z.number().int().min(0).describe('Left edge of the crop, in original-image pixels.'), + y: z.number().int().min(0).describe('Top edge of the crop, in original-image pixels.'), + width: z.number().int().min(1).describe('Crop width, in original-image pixels.'), + height: z.number().int().min(1).describe('Crop height, in original-image pixels.'), + }) + .optional() + .describe( + 'Images only: view just this rectangle of the image (original-image pixel coordinates). ' + + 'Use after a downsampled full view to inspect fine detail — a region within the size ' + + 'limits is delivered at full fidelity.', + ), + full_resolution: z + .boolean() + .optional() + .describe( + 'Images only: skip the default downscaling and view at native resolution. Fails with an ' + + 'explicit error when the payload would exceed the per-image byte limit; use region for ' + + 'files that large.', + ), +}); + +export type ReadMediaFileInput = z.infer; diff --git a/packages/agent-core-v2/src/agent/tools/read-media-file/read-media.md b/packages/agent-core-v2/src/agent/tools/read-media-file/read-media.md new file mode 100644 index 0000000000000000000000000000000000000000..5a3b43552e9aaa73150ee569935c0140898591cd --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/read-media-file/read-media.md @@ -0,0 +1,17 @@ +Read media content from a file. + +The path may be a `kimi-file://` attachment reference. Its bytes come from the current session's storage, independently of the workspace runtime, including after a fork. Any reported local attachment path belongs to the server; external converters must be able to access that filesystem. + +**Tips:** +- Make sure you follow the description of each tool parameter. +- A `` tag accompanies the media content; it summarizes the mime type, byte size and, for images, the original pixel dimensions, and states how the image was delivered (untouched, downsampled, cropped, or native resolution). When outputting coordinates, give relative coordinates first and compute absolute coordinates from the original image size. After generating or editing media via commands or scripts, read the result back before continuing. +- Large images are downsampled by default when automatic compression can safely fit them within model limits, which can blur fine detail (small text, dense UI). Compute absolute coordinates from the original dimensions reported in the `` block, never by measuring the displayed copy. When the `` tag reports downsampling and you need that detail, call this tool again with the `region` parameter (original-image pixel coordinates) to view a crop at full fidelity, or set `full_resolution` to true when the whole file fits the per-image byte limit. Re-reading the same file without these parameters just reproduces the same downsampled image. +- If automatic compression cannot safely produce an image within model limits, the tool returns an error and does not send the original image. Follow the error: use Bash or an available image-processing tool to create a smaller copy, then read that copy. Do not retry the unchanged file. +- The system will notify you when there is anything wrong when reading the file. +- This tool is a tool that you typically want to use in parallel. Always read multiple files in one response when possible. +- This tool can only read image or video files. To read text files, use the Read tool. To list directories, use `ls` via Bash for a known directory, or Glob for pattern search. +- If the file doesn't exist or path is invalid, an error will be returned. +- The maximum size that can be read is ${MAX_MEDIA_MEGABYTES}MB. An error will be returned if the file is larger than this limit. +- The media content will be returned in a form that you can directly view and understand. + +**Capabilities** diff --git a/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts b/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts new file mode 100644 index 0000000000000000000000000000000000000000..da02e7275c61b1a820596ae86efecf1dd35a5de9 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts @@ -0,0 +1,538 @@ +import type { ModelCapability } from '#human/llm/capability'; +import type { ContentPart } from '#human/llm/message'; +import { VideoUploadUnsupportedError } from '#/llm-adapter/contract/errors'; +import { inlineVideoPart, isMediaUploadAuthError } from '#/agent/media/videoUpload'; +import type { ITelemetryService } from '#/app/telemetry/telemetry'; +import type { ISessionMediaStore } from '#/agent/media/sessionMediaStore'; +import { isDaemonFileUrl } from '#/agent/media/mediaRef'; +import { attachmentFileSource, runtimeFileSource, withAttachmentLocation, type FileReadSource } from '#/agent/tools/fileReadSource'; + +import { RuntimeWorkspaceView } from '#/runtime/runtimeWorkspaceView'; +import type { HostEnvironmentInfo } from '#/os/interface/hostEnvironment'; +import { inspectAgentRuntime, type IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; +import { + ToolAccesses, + type AgentTool, + type ExecutableToolResult, + type ToolExecution, +} from '#/tool/toolContract'; +import { resolvePathAccessPath, type WorkspaceConfig } from '#/tool/path-access'; +import { + MEDIA_SNIFF_BYTES, + detectFileType, + sniffImageDimensions, +} from '#/agent/media/file-type'; +import { + MAX_IMAGE_DECODE_BYTES, + compressImageForModel, + cropImageForModel, + formatByteSize, + isRecodableImage, + resolveMaxImageEdgePx, + resolveReadImageByteBudget, + type ImageCropRegion, +} from '#/agent/media/image-compress'; +import { + buildImageConversionGuidance, + buildOversizedImageConversionGuidance, + isModelAcceptedImageMime, +} from '#/agent/media/image-format-policy'; +import { providerImagePolicy } from '#human/llm/media/image-formats'; +import { toInputJsonSchema } from '#/tool/input-schema'; +import { literalRulePattern, matchesGlobRuleSubject, matchesPathRuleSubject } from '#/tool/rule-match'; +import { renderPrompt } from '#/_base/utils/render-prompt'; +import { + MAX_MEDIA_BYTES, + MAX_MEDIA_MEGABYTES, + ReadMediaFileInputSchema, + type ReadMediaFileInput, + type VideoUploader, +} from './read-media-file'; +import readMediaDescriptionHead from './read-media.md?raw'; + +function buildDescription(capabilities: ModelCapability): string { + const head = renderPrompt(readMediaDescriptionHead, { MAX_MEDIA_MEGABYTES }); + const lines: string[] = [head]; + const hasImage = capabilities.image_in; + const hasVideo = capabilities.video_in; + if (hasImage && hasVideo) { + lines.push('- This tool supports image and video files for the current model.'); + } else if (hasImage) { + lines.push( + '- This tool supports image files for the current model.', + '- Video files are not supported by the current model.', + ); + } else if (hasVideo) { + lines.push( + '- This tool supports video files for the current model.', + '- Image files are not supported by the current model.', + ); + } else { + lines.push('- The current model does not support image or video input.'); + } + return lines.join('\n'); +} + +interface ImageDelivery { + readonly kind: 'untouched' | 'downsampled' | 'crop' | 'full'; + readonly width: number; + readonly height: number; + readonly byteLength: number; + readonly mimeType: string; + readonly region?: ImageCropRegion; + readonly resized?: boolean; +} + +function buildMediaNote(input: { + readonly kind: 'image' | 'video'; + readonly mimeType: string; + readonly byteSize: number; + readonly dimensions: { readonly width: number; readonly height: number } | null; + readonly delivery?: ImageDelivery; +}): string { + const parts: string[] = [ + `Read ${input.kind} file.`, + `Mime type: ${input.mimeType}.`, + `Size: ${String(input.byteSize)} bytes.`, + ]; + if (input.kind === 'image' && input.dimensions) { + parts.push( + `Original dimensions: ${String(input.dimensions.width)}x${String(input.dimensions.height)} pixels.`, + ); + } + const delivery = input.delivery; + if (delivery?.kind === 'downsampled') { + parts.push( + `The attached image was downsampled to ${String(delivery.width)}x${String(delivery.height)} pixels ` + + `(${delivery.mimeType}, ${formatByteSize(delivery.byteLength)}) to fit model limits; ` + + 'fine detail may be lost.', + 'To inspect fine detail, call ReadMediaFile again with the region parameter ' + + '(original-image pixel coordinates) to view a crop at full fidelity.', + ); + } else if (delivery?.kind === 'crop' && delivery.region) { + const { x, y, width, height } = delivery.region; + parts.push( + `Showing region (x=${String(x)}, y=${String(y)}, width=${String(width)}, height=${String(height)}) ` + + `of the original image${ + delivery.resized === true + ? `, downsampled to ${String(delivery.width)}x${String(delivery.height)} pixels` + : ' at native resolution' + }.`, + 'To output coordinates in original-image pixels, locate them within this crop and add ' + + `the region offset (x=${String(x)}, y=${String(y)}).`, + ); + } else if (delivery?.kind === 'full') { + parts.push('Shown at native resolution; no downscaling applied.'); + } + if (input.kind === 'image' && input.dimensions && delivery?.kind !== 'crop') { + parts.push( + 'If you need to output coordinates, output relative coordinates first ' + + 'and compute absolute coordinates using the original image size.', + ); + } + parts.push( + 'If you generate or edit images or videos via commands or scripts, ' + + 'read the result back immediately before continuing.', + ); + return `${parts.join(' ')}`; +} + +function buildImageDeliveryLimitError(input: { + readonly finalBytes: number; + readonly readByteBudget: number; + readonly maxEdge: number; +}): string { + return ( + `Image is too large to send safely after compression (${String(input.finalBytes)} bytes; ` + + `limit ${String(input.readByteBudget)} bytes and ${String(input.maxEdge)}px on the longest edge). ` + + 'The original image was not sent to the model. Do not retry the same file unchanged. ' + + 'Use Bash or an available image-processing tool to create a smaller copy within both limits, ' + + 'then call ReadMediaFile on the smaller copy.' + ); +} + +function buildImageDecodeLimitError(finalBytes: number): string { + return ( + `Image is too large to process safely for region or full_resolution (${String(finalBytes)} bytes; ` + + `safe decode limit ${String(MAX_IMAGE_DECODE_BYTES)} bytes). ` + + 'The original image was not sent to the model. Do not retry the same file unchanged. ' + + 'Use Bash or an available image-processing tool to create a smaller copy or crop the needed ' + + 'region into a separate image, then call ReadMediaFile on the resulting file.' + ); +} + +function buildFullResolutionLimitError( + path: string, + finalBytes: number, + inlineByteBudget: number, +): string { + return ( + `"${path}" is ${String(finalBytes)} bytes (${formatByteSize(finalBytes)}), ` + + `over the ${String(inlineByteBudget)}-byte (${formatByteSize(inlineByteBudget)}) ` + + 'per-image limit, so full_resolution cannot be honored. ' + + 'Use region to view a crop at full fidelity instead.' + ); +} + +function shouldSurfaceVideoUploadError(error: unknown, inlineVideoSupported: boolean): boolean { + if (error instanceof VideoUploadUnsupportedError) return !inlineVideoSupported; + return isMediaUploadAuthError(error); +} + +export class ReadMediaFileTool implements AgentTool { + declare readonly _serviceBrand: undefined; + readonly name = 'ReadMediaFile' as const; + readonly description: string; + readonly parameters: Record = toInputJsonSchema(ReadMediaFileInputSchema); + private readonly telemetry: ITelemetryService | undefined; + private readonly inlineVideoSupported: boolean; + private readonly providerType: string | undefined; + private readonly inlineImageByteBudget: number; + constructor( + private readonly runtime: IAgentRuntimeService, + private readonly workspace: WorkspaceConfig, + private readonly capabilities: ModelCapability, + private readonly videoUploader?: VideoUploader, + telemetry?: ITelemetryService, + inlineVideoSupported?: boolean, + providerType?: string, + private readonly attachmentStore?: ISessionMediaStore, + ) { + this.description = buildDescription(capabilities); + this.telemetry = telemetry; + this.inlineVideoSupported = inlineVideoSupported ?? false; + this.providerType = providerType; + this.inlineImageByteBudget = providerImagePolicy(providerType).inlineByteBudget; + } + + private async videoContentPart( + data: Buffer, + mimeType: string, + safePath: string, + ): Promise { + if (this.videoUploader !== undefined) { + try { + return await this.videoUploader({ + data, + mimeType, + filename: safePath.split(/[\\/]/).at(-1), + }); + } catch (error) { + if (shouldSurfaceVideoUploadError(error, this.inlineVideoSupported)) throw error; + } + } + return inlineVideoPart(data, mimeType); + } + + resolveExecution(args: ReadMediaFileInput): ToolExecution | Promise { + if (!args.path) { + return { isError: true, output: 'File path cannot be empty.' }; + } + if (isDaemonFileUrl(args.path)) { + return this.attachmentExecution(args); + } + const inspected = inspectAgentRuntime(this.runtime); + const env = inspected.environment; + const view = new RuntimeWorkspaceView(inspected, { + workDir: this.workspace.workspaceDir, + additionalDirs: this.workspace.additionalDirs, + }); + const workspace = { workspaceDir: view.workDir, additionalDirs: view.additionalDirs }; + const path = resolvePathAccessPath(args.path, { + env, + workspace, + operation: 'read', + }); + return { + accesses: ToolAccesses.readFile(path), + description: `Reading media: ${args.path}`, + display: { kind: 'file_io', operation: 'read', path }, + approvalRule: literalRulePattern(this.name, path), + matchesRule: (ruleArgs) => + matchesPathRuleSubject(ruleArgs, path, { + cwd: this.workspace.workspaceDir, + pathClass: env.pathClass, + homeDir: env.homeDir, + }), + execute: async () => { + const lease = this.runtime.acquire(['fs']); + try { + if (lease.runtime.identity.generation !== inspected.identity.generation) { + return { isError: true, output: 'Runtime changed before execution. Retry the tool call.' }; + } + return await this.execution(args, runtimeFileSource(lease.runtime.fs!, path), env); + } finally { + lease.dispose(); + } + }, + }; + } + + private async attachmentExecution(args: ReadMediaFileInput): Promise { + const source = await attachmentFileSource(args.path, this.attachmentStore); + return { + accesses: ToolAccesses.readFile(source.localPath ?? args.path), + description: `Reading media: ${args.path}`, + display: { kind: 'file_io', operation: 'read', path: source.localPath ?? args.path }, + approvalRule: literalRulePattern(this.name, args.path), + matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.path), + execute: async () => withAttachmentLocation( + await this.execution(args, source, { osKind: 'unknown' }), source, + ), + }; + } + + private async execution( + args: ReadMediaFileInput, + source: FileReadSource, + env: Pick, + ): Promise { + if (!args.path) { + return { isError: true, output: 'File path cannot be empty.' }; + } + + try { + const safePath = source.name; + const header = await source.readBytes(MEDIA_SNIFF_BYTES); + const fileType = detectFileType(safePath, header, 'media'); + + if (fileType.kind === 'text') { + return { + isError: true, + output: `"${args.path}" is a text file. Use Read to read text files.`, + }; + } + if (fileType.kind === 'unknown') { + return { + isError: true, + output: + `"${args.path}" is not a supported image or video file. ` + + 'Use Read for text files, or Bash or an MCP tool for other binary formats.', + }; + } + + if (fileType.kind === 'image' && !this.capabilities.image_in) { + return { + isError: true, + output: + 'The current model does not support image input. ' + + 'Tell the user to use a model with image input capability.', + }; + } + if ( + fileType.kind === 'image' && + !isModelAcceptedImageMime(fileType.mimeType, this.providerType) + ) { + return { + isError: true, + output: buildImageConversionGuidance(source.localPath ?? args.path, fileType.mimeType, env.osKind), + }; + } + if (fileType.kind === 'video' && !this.capabilities.video_in) { + return { + isError: true, + output: + 'The current model does not support video input. ' + + 'Tell the user to use a model with video input capability.', + }; + } + + const stat = await source.stat(); + if (stat.size === 0) { + return { isError: true, output: `"${args.path}" is empty.` }; + } + if (stat.size > MAX_MEDIA_BYTES) { + return { + isError: true, + output: + `"${args.path}" is ${String(stat.size)} bytes, which exceeds the ` + + `maximum ${String(MAX_MEDIA_MEGABYTES)}MB for media files.`, + }; + } + + if (fileType.kind === 'video' && (args.region !== undefined || args.full_resolution === true)) { + return { + isError: true, + output: 'region and full_resolution apply only to image files.', + }; + } + + if ( + fileType.kind === 'image' && + stat.size > MAX_IMAGE_DECODE_BYTES && + (args.region !== undefined || args.full_resolution === true) + ) { + return { + isError: true, + output: buildImageDecodeLimitError(stat.size), + }; + } + + if ( + fileType.kind === 'image' && + args.region === undefined && + args.full_resolution === true && + stat.size > this.inlineImageByteBudget + ) { + return { + isError: true, + output: buildFullResolutionLimitError(args.path, stat.size, this.inlineImageByteBudget), + }; + } + + const imageDeliveryLimits = { + readByteBudget: resolveReadImageByteBudget(), + maxEdge: resolveMaxImageEdgePx(), + }; + if ( + fileType.kind === 'image' && + args.region === undefined && + args.full_resolution !== true && + stat.size > MAX_IMAGE_DECODE_BYTES && + stat.size > imageDeliveryLimits.readByteBudget + ) { + return { + isError: true, + output: buildImageDeliveryLimitError({ + finalBytes: stat.size, + ...imageDeliveryLimits, + }), + }; + } + + const data = Buffer.from(await source.readBytes()); + let dimensions = fileType.kind === 'image' ? sniffImageDimensions(data) : null; + let mediaPart: ContentPart; + let delivery: ImageDelivery | undefined; + if (fileType.kind === 'image') { + if (args.region !== undefined) { + const outcome = await cropImageForModel(data, fileType.mimeType, args.region, { + skipResize: args.full_resolution === true, + telemetry: this.telemetry, + telemetrySource: 'read_media', + }); + if (!outcome.ok) { + return { isError: true, output: `Cannot read region from "${args.path}": ${outcome.error}` }; + } + const base64 = Buffer.from(outcome.data).toString('base64'); + mediaPart = { + type: 'image_url', + imageUrl: { url: `data:${outcome.mimeType};base64,${base64}` }, + }; + delivery = { + kind: 'crop', + width: outcome.width, + height: outcome.height, + byteLength: outcome.finalByteLength, + mimeType: outcome.mimeType, + region: outcome.region, + resized: outcome.resized, + }; + dimensions = { width: outcome.originalWidth, height: outcome.originalHeight }; + } else if (args.full_resolution === true) { + if (data.length > this.inlineImageByteBudget) { + return { + isError: true, + output: buildFullResolutionLimitError( + args.path, + data.length, + this.inlineImageByteBudget, + ), + }; + } + const base64 = data.toString('base64'); + mediaPart = { + type: 'image_url', + imageUrl: { url: `data:${fileType.mimeType};base64,${base64}` }, + }; + delivery = { + kind: 'full', + width: dimensions?.width ?? 0, + height: dimensions?.height ?? 0, + byteLength: data.length, + mimeType: fileType.mimeType, + }; + } else { + const { readByteBudget, maxEdge } = imageDeliveryLimits; + const inlineOnly = !isRecodableImage(data, fileType.mimeType); + const compressed = await compressImageForModel(data, fileType.mimeType, { + byteBudget: readByteBudget, + maxEdge, + telemetry: this.telemetry, + telemetrySource: 'read_media', + }); + if (inlineOnly) { + const inlineLimit = Math.max(readByteBudget, this.inlineImageByteBudget); + if (compressed.finalByteLength > inlineLimit) { + return { + isError: true, + output: buildOversizedImageConversionGuidance( + source.localPath ?? args.path, + fileType.mimeType, + env.osKind, + compressed.finalByteLength, + inlineLimit, + ), + }; + } + } else if ( + compressed.finalByteLength > readByteBudget || + Math.max(compressed.width, compressed.height) > maxEdge + ) { + return { + isError: true, + output: buildImageDeliveryLimitError({ + finalBytes: compressed.finalByteLength, + readByteBudget, + maxEdge, + }), + }; + } + const base64 = Buffer.from(compressed.data).toString('base64'); + mediaPart = { + type: 'image_url', + imageUrl: { url: `data:${compressed.mimeType};base64,${base64}` }, + }; + delivery = { + kind: compressed.changed ? 'downsampled' : 'untouched', + width: compressed.width, + height: compressed.height, + byteLength: compressed.finalByteLength, + mimeType: compressed.mimeType, + }; + if (compressed.changed) { + dimensions = { width: compressed.originalWidth, height: compressed.originalHeight }; + } + } + } else { + mediaPart = await this.videoContentPart(data, fileType.mimeType, safePath); + } + + const tag = fileType.kind === 'image' ? 'image' : 'video'; + const tagPath = isDaemonFileUrl(args.path) ? args.path : safePath; + const openText = `<${tag} path="${tagPath}">`; + const closeText = ``; + + const note = buildMediaNote({ + kind: fileType.kind, + mimeType: fileType.mimeType, + byteSize: stat.size, + dimensions, + delivery, + }); + + const output: ContentPart[] = [ + { type: 'text', text: openText }, + mediaPart, + { type: 'text', text: closeText }, + ]; + + return { output, note, isError: false }; + } catch (error) { + return { + isError: true, + output: `Failed to read ${args.path}: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } +} diff --git a/packages/agent-core-v2/src/agent/tools/select-tools/select-tools.ts b/packages/agent-core-v2/src/agent/tools/select-tools/select-tools.ts new file mode 100644 index 0000000000000000000000000000000000000000..ad8610c23b6baaaf527b00939564d4447ebb342b --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/select-tools/select-tools.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export const SelectToolsInputSchema = z + .object({ + names: z + .array(z.string()) + .min(1) + .describe('Exact tool names to load, taken from the latest announced tool list.'), + }) + .strict(); + +export type SelectToolsInput = z.infer; + +export interface ISelectToolsTool extends AgentTool { readonly _serviceBrand: undefined } +export const ISelectToolsTool = createDecorator('selectToolsTool'); diff --git a/packages/agent-core-v2/src/agent/tools/select-tools/selectToolsTool.ts b/packages/agent-core-v2/src/agent/tools/select-tools/selectToolsTool.ts new file mode 100644 index 0000000000000000000000000000000000000000..c12ce617f10e7185955ffb135f833118e68d4d49 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/select-tools/selectToolsTool.ts @@ -0,0 +1,57 @@ +import { toInputJsonSchema } from '#/tool/input-schema'; +import type { ToolExecution } from '#/tool/toolContract'; +import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; +import { IAgentToolSelectService, SELECT_TOOLS_TOOL_NAME } from '#/agent/toolSelect/toolSelect'; + +import { + ISelectToolsTool, + SelectToolsInputSchema, + type SelectToolsInput, +} from './select-tools'; + +const DESCRIPTION = + 'Load one or more tools by name so you can call them. ' + + 'All available tool names are listed in the / announcements ' + + 'in the system context — fold them in order to get the current list. ' + + 'Pass the exact name(s) you need; their full definitions become available immediately, ' + + 'so you can call them directly in your next tool call.'; + +export class SelectToolsTool implements ISelectToolsTool { + declare readonly _serviceBrand: undefined; + readonly name = SELECT_TOOLS_TOOL_NAME; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(SelectToolsInputSchema); + + constructor( + @IAgentToolSelectService private readonly toolSelect: IAgentToolSelectService, + ) {} + + resolveExecution(args: SelectToolsInput): ToolExecution { + return { + description: `Loading ${args.names.join(', ')}`, + approvalRule: this.name, + execute: async () => { + if (!this.toolSelect.enabled()) { + return { + output: 'select_tools is not available for the current model.', + isError: true, + }; + } + const { toLoad, alreadyAvailable, unknown } = this.toolSelect.load(args.names); + + const lines: string[] = []; + if (toLoad.length > 0) lines.push(`Loaded: ${toLoad.join(', ')}`); + if (alreadyAvailable.length > 0) { + lines.push(`Already available: ${alreadyAvailable.join(', ')}`); + } + for (const name of unknown) { + lines.push(`Unknown tool: ${name}. Pick from the latest announced tools list.`); + } + const isError = toLoad.length === 0 && alreadyAvailable.length === 0; + return isError ? { output: lines.join('\n'), isError } : { output: lines.join('\n') }; + }, + }; + } +} + +registerAgentToolService(ISelectToolsTool, SelectToolsTool, { name: SELECT_TOOLS_TOOL_NAME, domain: 'toolSelect' }); diff --git a/packages/agent-core-v2/src/agent/tools/task/task-list/task-list.md b/packages/agent-core-v2/src/agent/tools/task/task-list/task-list.md new file mode 100644 index 0000000000000000000000000000000000000000..075b29d059f0e0b1dda6debf8c98fde463c79575 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/task/task-list/task-list.md @@ -0,0 +1,25 @@ +List background tasks and their current status. + +Use this tool to discover which background tasks exist and where each one +stands. It is the entry point for inspecting background work: it returns a +task ID, status, and description for every task it reports, plus the command, +PID, and (once finished) exit code for shell tasks, and a stop reason for any +task that ended early. + +Guidelines: + +- After a context compaction, or whenever you are unsure which background + tasks are running or what their task IDs are, call this tool to + re-enumerate them instead of guessing a task ID. +- Prefer the default `active_only=true`, which lists only non-terminal tasks. + Pass `active_only=false` only when you specifically need to see tasks that + have already finished. With `active_only=false` the result may also include + `lost` tasks — tasks left over from a previous process that can no longer be + inspected or controlled; treat them as already terminated. +- `limit` caps how many tasks are returned. It accepts a value between 1 and + 100 and defaults to 20 when omitted. +- This tool only lists tasks; it does not return their output. Use it first + to locate the task ID you need, then call `TaskOutput` with that ID to read + the task's output and details. +- This tool is read-only and does not change any state, so it is always safe + to call, including in plan mode. diff --git a/packages/agent-core-v2/src/agent/tools/task/task-list/task-list.ts b/packages/agent-core-v2/src/agent/tools/task/task-list/task-list.ts new file mode 100644 index 0000000000000000000000000000000000000000..d70560e8b3b910c8b435586a1236090d8783b870 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/task/task-list/task-list.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export const TaskListInputSchema = z.object({ + active_only: z + .boolean() + .optional() + .default(true) + .describe('Whether to list only non-terminal background tasks.'), + limit: z + .number() + .int() + .min(1) + .max(100) + .default(20) + .describe('Maximum number of tasks to return.') + .optional(), +}); + +export type TaskListInput = z.infer; + +export interface ITaskListTool extends AgentTool { readonly _serviceBrand: undefined } +export const ITaskListTool = createDecorator('taskListTool'); diff --git a/packages/agent-core-v2/src/agent/tools/task/task-list/taskListTool.ts b/packages/agent-core-v2/src/agent/tools/task/task-list/taskListTool.ts new file mode 100644 index 0000000000000000000000000000000000000000..fd2a6e841e07ff2ea2606db4f72055900b3a462f --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/task/task-list/taskListTool.ts @@ -0,0 +1,45 @@ +import { toInputJsonSchema } from '#/tool/input-schema'; +import { matchesGlobRuleSubject } from '#/tool/rule-match'; +import { type ToolExecution } from '#/tool/toolContract'; +import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; + +import { IAgentTaskService } from '#/agent/task/task'; +import type { AgentTaskInfo } from '#/agent/task/task'; +import { formatPlainObject } from '#/agent/task/tools/format'; +import { ITaskListTool, TaskListInputSchema, type TaskListInput } from './task-list'; +import TASK_LIST_DESCRIPTION from './task-list.md?raw'; + +export function formatTaskList(tasks: readonly AgentTaskInfo[], activeOnly: boolean): string { + const label = activeOnly ? 'active_background_tasks' : 'background_tasks'; + const header = `${label}: ${String(tasks.length)}`; + if (tasks.length === 0) return `${header}\nNo background tasks found.`; + return `${header}\n${tasks.map((task) => formatPlainObject(task)).join('\n---\n')}`; +} + +export class TaskListTool implements ITaskListTool { + declare readonly _serviceBrand: undefined; + readonly name = 'TaskList' as const; + readonly description = TASK_LIST_DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(TaskListInputSchema); + + constructor(@IAgentTaskService private readonly tasks: IAgentTaskService) {} + + resolveExecution(args: TaskListInput): ToolExecution { + const listScope = (args.active_only ?? true) ? 'active' : 'all'; + return { + description: 'Listing background tasks', + approvalRule: this.name, + matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, listScope), + execute: async () => { + const activeOnly = args.active_only ?? true; + const tasks = this.tasks.list(activeOnly, args.limit ?? 20); + return { + output: formatTaskList(tasks, activeOnly), + isError: false, + }; + }, + }; + } +} + +registerAgentToolService(ITaskListTool, TaskListTool, { name: 'TaskList', domain: 'agentTask' }); diff --git a/packages/agent-core-v2/src/agent/tools/task/task-output/task-output.md b/packages/agent-core-v2/src/agent/tools/task/task-output/task-output.md new file mode 100644 index 0000000000000000000000000000000000000000..7e53c035d106e950c0aacd9bbf15e38ef249df0f --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/task/task-output/task-output.md @@ -0,0 +1,12 @@ +Retrieve a snapshot of a running or completed background task. + +Use this after `Bash(run_in_background=true)`, `Agent(run_in_background=true)`, or `AskUserQuestion(background=true)` to check progress, or to read the output of a task that has already completed. + +Guidelines: +- Prefer relying on automatic completion notifications. Use this tool only when you need task output before the automatic notification arrives. +- This tool is always non-blocking: it returns the current status/output snapshot immediately and never waits for the task to finish. +- Do not use TaskOutput to wait for a result you need before continuing — if your next step depends on the task's result, run that task in the foreground instead. TaskOutput is for a deliberate progress check you will act on without blocking, not a way to sit and wait for a background task you just launched. +- This tool returns structured task metadata, a fixed-size output preview, and an output_path for the full log. +- For a terminal task, the metadata also explains why it ended. A shell command that runs to completion reports `status: completed` on a zero exit, or `status: failed` with its non-zero `exit_code` — judge that failure from the `exit_code`, because a plain command failure carries no `stop_reason` and no `terminal_reason`. `terminal_reason` is a categorical label emitted only when the end is not an ordinary exit: `timed_out` when the deadline aborted it, `stopped` when it was explicitly stopped, or `failed` when it errored without producing an exit code; the `stopped` and `failed` cases also carry a human-readable `stop_reason`. A task that finished on its own with a clean exit carries neither `stop_reason` nor `terminal_reason`. +- The full, never-truncated log is always available at output_path; use the `Read` tool with that path to page through it, whether or not the preview was truncated. +- This tool works with the generic background task system and should remain the primary read path for future task types, not just bash. diff --git a/packages/agent-core-v2/src/agent/tools/task/task-output/task-output.ts b/packages/agent-core-v2/src/agent/tools/task/task-output/task-output.ts new file mode 100644 index 0000000000000000000000000000000000000000..ae6784936c9ed9ed53f6a3256cd29ca1eadbbe6f --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/task/task-output/task-output.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export const TaskOutputInputSchema = z.object({ + task_id: z.string().describe('The background task ID to inspect.'), +}); + +export type TaskOutputInput = z.infer; + +export interface ITaskOutputTool extends AgentTool { readonly _serviceBrand: undefined } +export const ITaskOutputTool = createDecorator('taskOutputTool'); diff --git a/packages/agent-core-v2/src/agent/tools/task/task-output/taskOutputTool.ts b/packages/agent-core-v2/src/agent/tools/task/task-output/taskOutputTool.ts new file mode 100644 index 0000000000000000000000000000000000000000..ebf691ff4060da7259181dee1f338abb89e45c96 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/task/task-output/taskOutputTool.ts @@ -0,0 +1,107 @@ +import { toInputJsonSchema } from '#/tool/input-schema'; +import { matchesGlobRuleSubject } from '#/tool/rule-match'; +import { type ExecutableToolResult, type ToolExecution } from '#/tool/toolContract'; +import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; + +import { IAgentTaskService } from '#/agent/task/task'; +import type { + AgentTaskInfo, + AgentTaskOutputSnapshot, +} from '#/agent/task/task'; +import { type AgentTaskStatus, TERMINAL_STATUSES } from '#/agent/task/types'; +import { formatPlainObject } from '#/agent/task/tools/format'; +import { ITaskOutputTool, TaskOutputInputSchema, type TaskOutputInput } from './task-output'; +import TASK_OUTPUT_DESCRIPTION from './task-output.md?raw'; + +const OUTPUT_PREVIEW_BYTES = 32 * 1024; + +const PAGING_HINT_LINES = 300; + +function retrievalStatus(status: AgentTaskStatus): 'success' | 'not_ready' { + return TERMINAL_STATUSES.has(status) ? 'success' : 'not_ready'; +} + +function terminalReason(info: AgentTaskInfo): 'timed_out' | 'stopped' | 'failed' | undefined { + if (info.status === 'timed_out') return 'timed_out'; + if (info.status === 'killed' && info.stopReason !== undefined) return 'stopped'; + if (info.status === 'failed' && info.stopReason !== undefined) return 'failed'; + return undefined; +} + +function fullOutputHint(output: AgentTaskOutputSnapshot): string | undefined { + if (!output.fullOutputAvailable || output.outputPath === undefined) return undefined; + if (output.truncated) { + return ( + `Only the last ${String(OUTPUT_PREVIEW_BYTES)} bytes are shown above. ` + + 'Use the Read tool with the output_path to page through the full log ' + + `(parameters: path, line_offset, n_lines; read about ${String(PAGING_HINT_LINES)} ` + + 'lines per page).' + ); + } + return ( + 'The preview above is the complete output. Use the Read tool with the output_path ' + + 'if you need to re-read the full log later ' + + `(parameters: path, line_offset, n_lines; read about ${String(PAGING_HINT_LINES)} ` + + 'lines per page).' + ); +} + +export class TaskOutputTool implements ITaskOutputTool { + declare readonly _serviceBrand: undefined; + readonly name = 'TaskOutput' as const; + readonly description: string = TASK_OUTPUT_DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(TaskOutputInputSchema); + + constructor(@IAgentTaskService private readonly tasks: IAgentTaskService) {} + + resolveExecution(args: TaskOutputInput): ToolExecution { + return { + description: `Reading output of task ${args.task_id}`, + approvalRule: this.name, + matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.task_id), + execute: () => this.execute(args), + }; + } + + private async execute(args: TaskOutputInput): Promise { + const current = this.tasks.getTask(args.task_id); + if (!current) { + return { isError: true, output: `Task not found: ${args.task_id}` }; + } + + const output = await this.tasks.getOutputSnapshot(args.task_id, OUTPUT_PREVIEW_BYTES); + + const lines = [ + formatPlainObject({ + retrievalStatus: retrievalStatus(current.status), + ...current, + outputPath: output.outputPath, + terminalReason: terminalReason(current), + outputSizeBytes: output.outputSizeBytes, + outputPreviewBytes: output.previewBytes, + outputTruncated: output.truncated, + fullOutputAvailable: output.fullOutputAvailable, + fullOutputTool: + output.fullOutputAvailable && output.outputPath !== undefined ? 'Read' : undefined, + fullOutputHint: fullOutputHint(output), + }), + '', + ]; + + if (output.truncated) { + lines.push( + output.fullOutputAvailable && output.outputPath !== undefined + ? `[Truncated. Full output: ${output.outputPath}]` + : '[Truncated. No persisted full log is available for this task.]', + ); + } + lines.push('[output]', output.preview || '[no output available]'); + + return { + output: lines.join('\n'), + isError: false, + }; + } +} + +registerAgentToolService(ITaskOutputTool, TaskOutputTool, { name: 'TaskOutput', domain: 'agentTask' }); diff --git a/packages/agent-core-v2/src/agent/tools/task/task-stop/task-stop.md b/packages/agent-core-v2/src/agent/tools/task/task-stop/task-stop.md new file mode 100644 index 0000000000000000000000000000000000000000..c1ff20a01b905e60f1c6b539399bf47c278af7ec --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/task/task-stop/task-stop.md @@ -0,0 +1,13 @@ +Stop a running background task. + +Only use this when a task must genuinely be cancelled — for a task that is +finishing normally, wait for its completion notification or inspect it with +`TaskOutput` instead of stopping it. + +Guidelines: +- This is a general-purpose stop capability for any background task. It is not + a bash-specific kill. +- Stopping a task is destructive: it may leave partial side effects behind. + Use it with care. +- If the task has already finished, this tool simply returns its current + status. diff --git a/packages/agent-core-v2/src/agent/tools/task/task-stop/task-stop.ts b/packages/agent-core-v2/src/agent/tools/task/task-stop/task-stop.ts new file mode 100644 index 0000000000000000000000000000000000000000..e153deb02ea2efb3702ac2cfcfcdcf2fadc1bf88 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/task/task-stop/task-stop.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export const TaskStopInputSchema = z.object({ + task_id: z.string().describe('The background task ID to stop.'), + reason: z + .string() + .default('Stopped by TaskStop') + .describe('Short reason recorded when the task is stopped.') + .optional(), +}); + +export type TaskStopInput = z.infer; + +export interface ITaskStopTool extends AgentTool { readonly _serviceBrand: undefined } +export const ITaskStopTool = createDecorator('taskStopTool'); diff --git a/packages/agent-core-v2/src/agent/tools/task/task-stop/taskStopTool.ts b/packages/agent-core-v2/src/agent/tools/task/task-stop/taskStopTool.ts new file mode 100644 index 0000000000000000000000000000000000000000..40f873c65099f1f38b07f2c5733ee1134f587d7b --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/task/task-stop/taskStopTool.ts @@ -0,0 +1,69 @@ +import { toInputJsonSchema } from '#/tool/input-schema'; +import { matchesGlobRuleSubject } from '#/tool/rule-match'; +import { type ToolExecution } from '#/tool/toolContract'; +import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; + +import { IAgentTaskService } from '#/agent/task/task'; +import { TERMINAL_STATUSES } from '#/agent/task/types'; +import { ITaskStopTool, TaskStopInputSchema, type TaskStopInput } from './task-stop'; +import TASK_STOP_DESCRIPTION from './task-stop.md?raw'; + +export class TaskStopTool implements ITaskStopTool { + declare readonly _serviceBrand: undefined; + readonly name = 'TaskStop' as const; + readonly description = TASK_STOP_DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(TaskStopInputSchema); + + constructor(@IAgentTaskService private readonly tasks: IAgentTaskService) {} + + resolveExecution(args: TaskStopInput): ToolExecution { + return { + description: `Stopping task ${args.task_id}`, + approvalRule: this.name, + matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.task_id), + execute: async () => { + const info = this.tasks.getTask(args.task_id); + if (!info) { + return { isError: true, output: `Task not found: ${args.task_id}` }; + } + + const trimmedReason = args.reason?.trim(); + const reason = + trimmedReason === undefined || trimmedReason.length === 0 + ? 'Stopped by TaskStop' + : trimmedReason; + + if (TERMINAL_STATUSES.has(info.status)) { + return { + output: + `task_id: ${info.taskId}\n` + + `status: ${info.status}\n` + + `reason: ${terminalStopReason(info.stopReason)}`, + isError: false, + }; + } + + await this.tasks.suppressTerminalNotification(args.task_id); + const result = await this.tasks.stop(args.task_id, reason); + if (!result) { + return { isError: true, output: `Failed to stop task: ${args.task_id}` }; + } + + return { + output: + `task_id: ${result.taskId}\n` + + `status: ${result.status}\n` + + `reason: ${result.stopReason ?? reason}`, + isError: false, + }; + }, + }; + } +} + +registerAgentToolService(ITaskStopTool, TaskStopTool, { name: 'TaskStop', domain: 'agentTask' }); + +function terminalStopReason(reason: string | undefined): string { + const trimmed = reason?.trim(); + return trimmed === undefined || trimmed.length === 0 ? 'Task already in terminal state' : trimmed; +} diff --git a/packages/agent-core-v2/src/agent/tools/task/task-wait/flag.ts b/packages/agent-core-v2/src/agent/tools/task/task-wait/flag.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd42774b777624c223cc83353b6681f2db159ba4 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/task/task-wait/flag.ts @@ -0,0 +1,16 @@ +import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry'; + +export const WAIT_FOR_FLAG_ID = 'wait_for'; +export const WAIT_FOR_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_WAIT_FOR'; + +export const waitForFlag: FlagDefinitionInput = { + id: WAIT_FOR_FLAG_ID, + title: 'WaitFor tool', + description: + 'Give the model the WaitFor tool so it can wait for background tasks inside the current turn instead of ending the turn and being re-invoked.', + env: WAIT_FOR_FLAG_ENV, + default: true, + surface: 'core', +}; + +registerFlagDefinition(waitForFlag); diff --git a/packages/agent-core-v2/src/agent/tools/task/task-wait/task-wait.md b/packages/agent-core-v2/src/agent/tools/task/task-wait/task-wait.md new file mode 100644 index 0000000000000000000000000000000000000000..30ebbc8fa6417945be2cc23f2562ac76b08315af --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/task/task-wait/task-wait.md @@ -0,0 +1,16 @@ +Wait for background tasks to finish without ending the current turn. + +Use this when your next step depends on the result of a running background task (a sub-agent, a background bash command, or a background AskUserQuestion). The call suspends inside the current turn until the task finishes or the timeout elapses, then returns the outcome so you can keep working in the same turn. While waiting, no LLM requests are made. + +Guidelines: + +- Do not call WaitFor right after dispatching work whose result you do not need yet — finished background tasks notify you automatically. WaitFor is for the moment you genuinely cannot proceed without a result. +- `timeout` is required, in seconds, capped at 600. To wait longer, call WaitFor again; waking up periodically also lets you re-evaluate the situation. +- A timeout is not an error: the result lists the tasks that are still running, and you decide whether to wait again or do other work meanwhile. +- Without `task_id`, the wait ends as soon as any background task that was running at call time finishes. Tasks started during the wait are not covered by it; their completion arrives via the usual automatic notification. +- With `task_id`, the wait ends when that task finishes. An unknown `task_id` is an error; a task that has already finished returns immediately. +- When no background tasks are running, WaitFor returns immediately without waiting. +- When the wait ends because a task finished, the result also lists other tasks that finished during the wait window, so failures surface with context. +- Waiting has no side effects on the waited tasks: WaitFor never stops a task, and interrupting the wait (for example, a user interruption) leaves every task running. +- A finished task's result is delivered exactly once: tasks reported by WaitFor do not also produce an automatic completion notification. +- You can only wait for background tasks started by this agent; task IDs belonging to other agents are unknown here. diff --git a/packages/agent-core-v2/src/agent/tools/task/task-wait/task-wait.ts b/packages/agent-core-v2/src/agent/tools/task/task-wait/task-wait.ts new file mode 100644 index 0000000000000000000000000000000000000000..69b001414e14f11a80678e33e6d5cc9229145259 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/task/task-wait/task-wait.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; +import { DEFAULT_BACKGROUND_TIMEOUT_S } from '#/agent/tools/os/bash/bash'; + +export const WAIT_FOR_MAX_TIMEOUT_S = DEFAULT_BACKGROUND_TIMEOUT_S; + +export const WaitForInputSchema = z.object({ + timeout: z + .number() + .int() + .positive() + .max(WAIT_FOR_MAX_TIMEOUT_S) + .describe( + `Maximum time to wait, in seconds (1-${String(WAIT_FOR_MAX_TIMEOUT_S)}). A timeout is not an error: the tool returns the tasks that are still running, and you can call it again to keep waiting.`, + ), + task_id: z + .string() + .optional() + .describe( + 'The background task ID to wait for. When omitted, the wait ends as soon as any background task that was running at call time finishes.', + ), +}); + +export type WaitForInput = z.infer; + +export interface IWaitForTool extends AgentTool { readonly _serviceBrand: undefined } +export const IWaitForTool = createDecorator('waitForTool'); diff --git a/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts b/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts new file mode 100644 index 0000000000000000000000000000000000000000..9856173d31d61c26ded061d18811d428ccc5bd7e --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts @@ -0,0 +1,366 @@ +import { toInputJsonSchema } from '#/tool/input-schema'; +import { matchesGlobRuleSubject } from '#/tool/rule-match'; +import { + type ExecutableToolContext, + type ExecutableToolResult, + type ToolExecution, + type ToolUpdate, +} from '#/tool/toolContract'; +import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; + +import { IAgentTaskService } from '#/agent/task/task'; +import type { AgentTaskInfo, AgentTaskOutputSnapshot } from '#/agent/task/task'; +import { TERMINAL_STATUSES } from '#/agent/task/types'; +import { formatPlainObject } from '#/agent/task/tools/format'; +import { formatTaskList } from '#/agent/tools/task/task-list/taskListTool'; +import { IFlagService } from '#/app/flag/flag'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { abortError, isAbortError, linkAbortSignal } from '#/_base/utils/abort'; +import { WAIT_FOR_FLAG_ID } from './flag'; +import { IWaitForTool, WaitForInputSchema, type WaitForInput } from './task-wait'; +import WAIT_FOR_DESCRIPTION from './task-wait.md?raw'; + +const OUTPUT_PREVIEW_BYTES = 32 * 1024; + +const PAGING_HINT_LINES = 300; + +const PROGRESS_INTERVAL_MS = 1_000; + +type WaitForOutcome = 'completed' | 'timed_out' | 'task_not_found' | 'aborted' | 'interrupted'; + +function terminalReason(info: AgentTaskInfo): 'timed_out' | 'stopped' | 'failed' | undefined { + if (info.status === 'timed_out') return 'timed_out'; + if (info.status === 'killed' && info.stopReason !== undefined) return 'stopped'; + if (info.status === 'failed' && info.stopReason !== undefined) return 'failed'; + return undefined; +} + +function fullOutputHint(output: AgentTaskOutputSnapshot): string | undefined { + if (!output.fullOutputAvailable || output.outputPath === undefined) return undefined; + if (output.truncated) { + return ( + `Only the last ${String(OUTPUT_PREVIEW_BYTES)} bytes are shown above. ` + + 'Use the Read tool with the output_path to page through the full log ' + + `(parameters: path, line_offset, n_lines; read about ${String(PAGING_HINT_LINES)} ` + + 'lines per page).' + ); + } + return ( + 'The preview above is the complete output. Use the Read tool with the output_path ' + + 'if you need to re-read the full log later ' + + `(parameters: path, line_offset, n_lines; read about ${String(PAGING_HINT_LINES)} ` + + 'lines per page).' + ); +} + +export function waitForProgressUpdate( + args: WaitForInput, + runningCount: number, + startedAt: number, + now: number, +): ToolUpdate { + const elapsedS = Math.max(0, Math.round((now - startedAt) / 1000)); + return { + kind: 'status', + text: + `Waiting ${formatWaitSeconds(elapsedS)} / ${formatWaitSeconds(args.timeout)} · ` + + `${String(runningCount)} background task${runningCount === 1 ? '' : 's'} still running`, + replace: true, + }; +} + +function formatWaitSeconds(totalSeconds: number): string { + if (totalSeconds < 60) return `${String(totalSeconds)}s`; + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes < 60) { + return seconds === 0 + ? `${String(minutes)}m` + : `${String(minutes)}m ${seconds.toString().padStart(2, '0')}s`; + } + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return remainingMinutes === 0 + ? `${String(hours)}h` + : `${String(hours)}h ${remainingMinutes.toString().padStart(2, '0')}m`; +} + +export interface WaitForProgressHandle { + readonly stop: () => void; + readonly tick: () => void; +} + +export function startWaitProgress( + args: WaitForInput, + tasks: Pick, + onUpdate: ((update: ToolUpdate) => void) | undefined, + startedAt: number, +): WaitForProgressHandle { + if (onUpdate === undefined) return { stop: () => {}, tick: () => {} }; + const tick = (): void => { + onUpdate(waitForProgressUpdate(args, tasks.list(true).length, startedAt, Date.now())); + }; + tick(); + const interval = setInterval(tick, PROGRESS_INTERVAL_MS); + interval.unref?.(); + return { + stop: () => { + clearInterval(interval); + }, + tick, + }; +} + +export class WaitForTool implements IWaitForTool { + declare readonly _serviceBrand: undefined; + readonly name = 'WaitFor' as const; + readonly description: string = WAIT_FOR_DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(WaitForInputSchema); + + constructor( + @IAgentTaskService private readonly tasks: IAgentTaskService, + @ITelemetryService private readonly telemetry: ITelemetryService, + @IFlagService private readonly flags: IFlagService, + ) {} + + resolveExecution(args: WaitForInput): ToolExecution { + return { + description: + args.task_id === undefined + ? `Waiting up to ${String(args.timeout)}s for any background task` + : `Waiting up to ${String(args.timeout)}s for task ${args.task_id}`, + approvalRule: this.name, + matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.task_id ?? 'any'), + execute: (ctx) => this.execute(args, ctx), + }; + } + + private async execute( + args: WaitForInput, + ctx: ExecutableToolContext, + ): Promise { + if (!this.flags.enabled(WAIT_FOR_FLAG_ID)) { + return { + isError: true, + output: 'WaitFor is disabled: the wait_for experimental flag is off.', + }; + } + const startedAt = Date.now(); + const timeoutMs = args.timeout * 1000; + const runningAtStart = this.tasks.list(true); + + if (args.task_id === undefined) { + if (runningAtStart.length === 0) { + this.track(args, startedAt, timeoutMs, 'completed', 0); + return { + output: [ + formatPlainObject({ waitStatus: 'no_tasks', waitedMs: 0, timeoutMs }), + 'No background tasks are running, so there is nothing to wait for. Finished tasks report back via automatic notification.', + ].join('\n\n'), + isError: false, + }; + } + } else if (this.tasks.getTask(args.task_id) === undefined) { + this.track(args, startedAt, timeoutMs, 'task_not_found', 0); + return { isError: true, output: `Task not found: ${args.task_id}` }; + } + + let waited: AgentTaskInfo | undefined; + const signal = ctx.steerSignal === undefined + ? ctx.signal + : AbortSignal.any([ctx.signal, ctx.steerSignal]); + const progress = startWaitProgress(args, this.tasks, ctx.onUpdate, startedAt); + try { + waited = + args.task_id === undefined + ? await this.waitAny(runningAtStart, timeoutMs, signal) + : await this.tasks.wait(args.task_id, timeoutMs, signal); + } catch (error) { + if ( + !ctx.signal.aborted && ctx.steerSignal?.aborted && + (error === ctx.steerSignal.reason || isAbortError(error)) + ) { + this.track(args, startedAt, timeoutMs, 'interrupted', 0); + return { output: this.formatInterrupted(args, startedAt, timeoutMs), isError: false }; + } + this.track(args, startedAt, timeoutMs, 'aborted', 0); + throw error; + } finally { + progress.stop(); + } + + if (waited === undefined) { + this.track(args, startedAt, timeoutMs, 'task_not_found', 0); + return { isError: true, output: `Task not found: ${args.task_id ?? ''}` }; + } + + if (!TERMINAL_STATUSES.has(waited.status)) { + this.track(args, startedAt, timeoutMs, 'timed_out', 0); + return { output: this.formatTimeout(args, startedAt, timeoutMs), isError: false }; + } + + const extras = this.collectExtras(runningAtStart, waited.taskId); + const output = await this.formatCompleted(waited, extras, startedAt, timeoutMs); + this.tasks.markTasksDeliveredViaWait( + [waited, ...extras].map((info) => ({ taskId: info.taskId, status: info.status })), + ); + this.track(args, startedAt, timeoutMs, 'completed', extras.length); + return { output, isError: false }; + } + + private async waitAny( + running: readonly AgentTaskInfo[], + timeoutMs: number, + signal: AbortSignal, + ): Promise { + const controller = new AbortController(); + const unlink = linkAbortSignal(signal, controller); + try { + const outcomes = running.map((task) => + this.tasks.wait(task.taskId, timeoutMs, controller.signal).then( + (info) => ({ info, error: undefined }), + (error: unknown) => ({ + info: undefined, + error: error instanceof Error ? error : new Error(String(error)), + }), + ), + ); + const first = await Promise.race(outcomes); + if (first.error !== undefined) throw first.error; + return first.info; + } finally { + unlink(); + controller.abort(abortError()); + } + } + + private collectExtras( + runningAtStart: readonly AgentTaskInfo[], + finishedTaskId: string, + ): AgentTaskInfo[] { + const extras: AgentTaskInfo[] = []; + for (const task of runningAtStart) { + if (task.taskId === finishedTaskId) continue; + const current = this.tasks.getTask(task.taskId); + if (current !== undefined && TERMINAL_STATUSES.has(current.status)) extras.push(current); + } + return extras; + } + + private formatTimeout(args: WaitForInput, startedAt: number, timeoutMs: number): string { + const lines = [ + formatPlainObject({ + waitStatus: 'timed_out', + taskId: args.task_id, + waitedMs: Date.now() - startedAt, + timeoutMs, + }), + 'The wait ended before the task finished — a timeout is not an error. Call WaitFor again to keep waiting, or continue with other work; completion also arrives via automatic notification.', + ]; + const running = this.tasks.list(true); + if (running.length > 0) { + lines.push('', '[still_running]', formatTaskList(running, true)); + } + return lines.join('\n'); + } + + private formatInterrupted(args: WaitForInput, startedAt: number, timeoutMs: number): string { + const lines = [ + formatPlainObject({ + waitStatus: 'interrupted', + reason: 'steer', + taskId: args.task_id, + waitedMs: Date.now() - startedAt, + timeoutMs, + }), + 'New input ended this wait early. Read the new input before deciding what to do next. Background tasks have not been stopped; completion still arrives via automatic notification.', + ]; + const running = this.tasks.list(true); + if (running.length > 0) { + lines.push('', '[still_running]', formatTaskList(running, true)); + } + return lines.join('\n'); + } + + private async formatCompleted( + finished: AgentTaskInfo, + extras: readonly AgentTaskInfo[], + startedAt: number, + timeoutMs: number, + ): Promise { + const lines = [ + formatPlainObject({ + waitStatus: 'completed', + taskId: finished.taskId, + waitedMs: Date.now() - startedAt, + timeoutMs, + }), + '', + '[finished]', + ...(await this.formatFinishedTask(finished)), + ]; + if (extras.length > 0) { + lines.push( + '', + '[completed_during_wait]', + extras.map((extra) => formatPlainObject(extra)).join('\n---\n'), + 'Use TaskOutput with one of the task_id values above to read the full output.', + ); + } + const running = this.tasks.list(true); + if (running.length > 0) { + lines.push('', '[still_running]', formatTaskList(running, true)); + } + return lines.join('\n'); + } + + private async formatFinishedTask(info: AgentTaskInfo): Promise { + const output = await this.tasks.getOutputSnapshot(info.taskId, OUTPUT_PREVIEW_BYTES); + const lines = [ + formatPlainObject({ + ...info, + outputPath: output.outputPath, + terminalReason: terminalReason(info), + outputSizeBytes: output.outputSizeBytes, + outputPreviewBytes: output.previewBytes, + outputTruncated: output.truncated, + fullOutputAvailable: output.fullOutputAvailable, + fullOutputTool: + output.fullOutputAvailable && output.outputPath !== undefined ? 'Read' : undefined, + fullOutputHint: fullOutputHint(output), + }), + '', + ]; + if (output.truncated) { + lines.push( + output.fullOutputAvailable && output.outputPath !== undefined + ? `[Truncated. Full output: ${output.outputPath}]` + : '[Truncated. No persisted full log is available for this task.]', + ); + } + lines.push('[output]', output.preview || '[no output available]'); + return lines; + } + + private track( + args: WaitForInput, + startedAt: number, + timeoutMs: number, + outcome: WaitForOutcome, + extraCompletedCount: number, + ): void { + this.telemetry.track2('wait_for_completed', { + outcome, + timeout_ms: timeoutMs, + waited_ms: Date.now() - startedAt, + has_task_id: args.task_id !== undefined, + extra_completed_count: extraCompletedCount, + }); + } +} + +registerAgentToolService(IWaitForTool, WaitForTool, { + name: 'WaitFor', + domain: 'agentTask', + when: (accessor) => accessor.get(IFlagService).enabled(WAIT_FOR_FLAG_ID), +}); diff --git a/packages/agent-core-v2/src/agent/tools/web-search/web-search.md b/packages/agent-core-v2/src/agent/tools/web-search/web-search.md new file mode 100644 index 0000000000000000000000000000000000000000..26c79f5f9f74865418698f57c3edaffee7f7fbb0 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/web-search/web-search.md @@ -0,0 +1,5 @@ +Search the web for information. Use this when you need up-to-date information from the internet. + +Each result includes its title, its URL, and a snippet, plus its source site and publication date when available. Results are short summaries, not full pages — when a result looks relevant, call the FetchURL tool on its URL to read the full page content. Fetch only the few URLs you actually need. Prefer specific queries, and refine the query if the results don't contain what you need. + +When you rely on a result in your answer, cite its source URL so the user can verify it. diff --git a/packages/agent-core-v2/src/agent/tools/web-search/web-search.ts b/packages/agent-core-v2/src/agent/tools/web-search/web-search.ts new file mode 100644 index 0000000000000000000000000000000000000000..9acb3068f6a95ed43b2d03d424734d131ae6e71c --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/web-search/web-search.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export interface WebSearchResult { + title: string; + url: string; + snippet: string; + date?: string; + siteName?: string; +} + +export interface WebSearchProvider { + search( + query: string, + options?: { + toolCallId?: string; + signal?: AbortSignal; + }, + ): Promise; +} + +export const WebSearchInputSchema = z.object({ + query: z.string().describe('The query text to search for.'), +}); + +export type WebSearchInput = z.infer; + +export interface IWebSearchTool extends AgentTool { + readonly _serviceBrand: undefined; +} +export const IWebSearchTool = createDecorator('webSearchTool'); diff --git a/packages/agent-core-v2/src/agent/tools/web-search/webSearchTool.ts b/packages/agent-core-v2/src/agent/tools/web-search/webSearchTool.ts new file mode 100644 index 0000000000000000000000000000000000000000..d16d1b990bd3d7b58c46c358866ee3b528176f66 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/web-search/webSearchTool.ts @@ -0,0 +1,118 @@ +import { toInputJsonSchema } from '#/tool/input-schema'; +import { literalRulePattern, matchesGlobRuleSubject } from '#/tool/rule-match'; +import { + ToolAccesses, + type ExecutableToolContext, + type ExecutableToolResult, + type ToolExecution, +} from '#/tool/toolContract'; +import { ToolOutputAccumulator } from '#/tool/output-accumulator'; +import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; +import { IWebSearchProviderService } from '#/app/auth/webSearch/webSearch'; + +import { + IWebSearchTool, + WebSearchInputSchema, + type WebSearchInput, +} from './web-search'; +import DESCRIPTION from './web-search.md?raw'; + +export class WebSearchTool implements IWebSearchTool { + declare readonly _serviceBrand: undefined; + readonly name = 'WebSearch' as const; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(WebSearchInputSchema); + + constructor( + @IWebSearchProviderService private readonly providerService: IWebSearchProviderService, + ) {} + + resolveExecution(args: WebSearchInput): ToolExecution { + const preview = args.query.length > 40 ? `${args.query.slice(0, 40)}…` : args.query; + return { + accesses: ToolAccesses.none(), + description: `Searching: ${preview}`, + display: { kind: 'search', query: args.query }, + approvalRule: literalRulePattern(this.name, args.query), + matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.query), + execute: (ctx) => this.execution(args, ctx), + }; + } + + private async execution( + args: WebSearchInput, + { toolCallId, signal }: ExecutableToolContext, + ): Promise { + const provider = this.providerService.getWebSearchProvider(); + if (provider === undefined) { + return { + isError: true, + output: 'Web search is no longer configured; the provider was removed after this session started.', + }; + } + try { + const results = await provider.search(args.query, { toolCallId, signal }); + const builder = new ToolOutputAccumulator(); + + if (results.length === 0) { + builder.write('No search results found.'); + return builder.ok(); + } + + let first = true; + for (const result of results) { + if (!first) builder.write('---\n\n'); + first = false; + + builder.write(`Title: ${result.title}\n`); + if (result.siteName) builder.write(`Site: ${result.siteName}\n`); + if (result.date) builder.write(`Date: ${result.date}\n`); + builder.write(`URL: ${result.url}\n`); + builder.write(`Snippet: ${result.snippet}\n\n`); + } + + builder.write( + 'When you rely on a result in your answer, cite it inline as a markdown link, e.g. [title](url).', + ); + + return builder.ok(); + } catch (error) { + if (signal.aborted) throw error; + return { + isError: true, + output: classifySearchError(error), + }; + } + } +} + +function classifySearchError(error: unknown): string { + const name = error instanceof Error ? error.name : ''; + const message = error instanceof Error ? error.message : String(error); + const lower = message.toLowerCase(); + + if (name === 'AbortError' || lower.includes('abort')) { + return `Search cancelled: ${message}`; + } + if (name === 'TimeoutError' || lower.includes('timed out') || lower.includes('timeout')) { + return `Search timed out: ${message}`; + } + if (lower.includes('401') || lower.includes('unauthorized') || lower.includes('auth')) { + return `Search failed (authentication): ${message}`; + } + if ( + lower.includes('http ') || + lower.includes('network') || + lower.includes('fetch') || + name === 'TypeError' + ) { + return `Search failed (network): ${message}`; + } + return `Search failed: ${message}`; +} + +registerAgentToolService(IWebSearchTool, WebSearchTool, { + name: 'WebSearch', + domain: 'auth', + when: (accessor) => accessor.get(IWebSearchProviderService).hasWebSearchProvider(), +}); diff --git a/packages/agent-core-v2/src/agent/undo/undo.ts b/packages/agent-core-v2/src/agent/undo/undo.ts new file mode 100644 index 0000000000000000000000000000000000000000..09f690161e6f4ae95aa815fbd4439488df32224a --- /dev/null +++ b/packages/agent-core-v2/src/agent/undo/undo.ts @@ -0,0 +1,16 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface UndoAvailability { + readonly maxTurns: number; + readonly stoppedAtCompaction: boolean; +} + +export interface IAgentConversationUndoService { + readonly _serviceBrand: undefined; + + availability(): UndoAvailability; + undo(turns: number): Promise; +} + +export const IAgentConversationUndoService: ServiceIdentifier = + createDecorator('agentConversationUndoService'); diff --git a/packages/agent-core-v2/src/agent/undo/undoService.ts b/packages/agent-core-v2/src/agent/undo/undoService.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ed2f6802f239b6e46b5e4a41c865f3538a70b12 --- /dev/null +++ b/packages/agent-core-v2/src/agent/undo/undoService.ts @@ -0,0 +1,273 @@ +import type { UserPromptOrigin } from '#/agent/contextMemory/types'; +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { type IDisposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; +import { BugIndicatingError } from '#/_base/errors/errors'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { IAgentConversationUndoParticipantRegistry } from '#/agent/contextMemory/conversationUndoParticipants'; +import { + computeUndoCut, + formatUndoUnavailableMessage, + precheckUndo, +} from '#/agent/contextMemory/contextOps'; +import { + isUndoAnchor, + isValidUndoCount, +} from '#/agent/contextMemory/conversationTime'; +import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; +import { IAgentLoopService } from '#/agent/loop/loop'; +import { turnKey } from '#/agent/loop/turnOps'; +import { promptMetadataTextFromContentParts } from '#/agent/prompt/promptMetadataText'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { IEventService } from '#/app/event/event'; +import { AgentEvent2 } from '#/app/event/event2'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { ErrorCodes, Error2 } from '#/errors'; +import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { SessionMetaUpdated } from '#/session/sessionMetadata/sessionMetaEvents'; +import { ISessionTokenCountingService } from '#/session/tokenCounting/sessionTokenCounting'; +import { IEventDispatcher } from '#/state/eventDispatcher'; +import { ForkLineError, IWireService } from '#/wire/wire'; + +import { IAgentConversationUndoService, type UndoAvailability } from './undo'; + +export class ContextUndone extends AgentEvent2<{ + readonly agentId: string; + readonly turns: number; + readonly fromTurnId?: number; +}> { + static override readonly type = 'context.undone'; + static override readonly observable = true; +} +export interface ContextUndone { + readonly agentId: string; + readonly turns: number; + readonly fromTurnId?: number; +} + +export class AgentConversationUndoService + extends Service + implements IAgentConversationUndoService +{ + declare readonly _serviceBrand: undefined; + + private undoQueue: Promise = Promise.resolve(); + + constructor( + @IAgentLoopService private readonly loop: IAgentLoopService, + @IAgentFullCompactionService private readonly fullCompaction: IAgentFullCompactionService, + @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + @IAgentConversationUndoParticipantRegistry + private readonly participants: IAgentConversationUndoParticipantRegistry, + @IAgentScopeContext private readonly agentCtx: IAgentScopeContext, + @ISessionContext private readonly session: ISessionContext, + @ISessionMetadata private readonly metadata: ISessionMetadata, + @IEventService private readonly eventService: IEventService, + @ITelemetryService private readonly telemetry: ITelemetryService, + @IEventDispatcher private readonly dispatcher: IEventDispatcher, + @IAgentStateService private readonly agentState: IAgentStateService, + @ISessionTokenCountingService private readonly tokenCounting: ISessionTokenCountingService, + @IWireService private readonly wire: IWireService, + @ILogService private readonly log: ILogService, + ) { + super(); + } + + availability(): UndoAvailability { + const cut = computeUndoCut(this.context.get(), Number.MAX_SAFE_INTEGER); + return { + maxTurns: cut.removedCount, + stoppedAtCompaction: cut.stoppedAtCompaction, + }; + } + + async undo(turns: number): Promise { + if (!isValidUndoCount(turns)) { + throw new Error2( + ErrorCodes.REQUEST_INVALID, + 'Undo count must be a positive safe integer', + { details: { field: 'count' } }, + ); + } + const run = this.undoQueue.then(() => this.undoNow(turns)); + this.undoQueue = run.then( + () => undefined, + () => undefined, + ); + return run; + } + + private async undoNow(turns: number): Promise { + let quiescence: IDisposable | undefined; + try { + quiescence = this.loop.tryAcquireQuiescence(); + if (quiescence === undefined) { + throw this.busyError('loop'); + } + if (this.fullCompaction.compacting !== null) { + throw this.busyError('compaction'); + } + if (this.dispatcher.restorePhase !== 'ready') { + throw new BugIndicatingError( + `Conversation undo requires a restored dispatcher (phase '${this.dispatcher.restorePhase}')`, + ); + } + this.assertUndoAvailable(turns); + const fromTurnId = this.removedFromTurnId(turns); + try { + await this.wire.switchBranch({ turns, fromTurnId }); + } catch (error) { + throw this.forkError(error, turns); + } + try { + await this.dispatcher.restore(); + } catch (error) { + this.log.warn('undo state restore failed; retrying once', { error }); + await this.dispatcher.restore(); + } + await this.loop.resetMachineEngine(); + this.tokenCounting.recordTruncation( + this.agentCtx.agentContext, + this.context.get().length, + ); + await this.reconcileParticipants(); + await this.flushAfterReconcile(); + await this.reconcileParticipants('after-flush'); + await this.reconcileLastPromptSafely(); + this.telemetry.track2('conversation_undo', { count: turns }); + await this.dispatcher.dispatch( + new ContextUndone({ agentId: this.agentCtx.agentId, turns, fromTurnId }), + ); + return turns; + } finally { + quiescence?.dispose(); + } + } + + private forkError(error: unknown, turns: number): unknown { + if (!(error instanceof ForkLineError)) return error; + return new Error2( + ErrorCodes.SESSION_UNDO_UNAVAILABLE, + formatUndoUnavailableMessage({ + ok: false, + reason: error.reason, + requested: turns, + undoable: error.available, + }), + { + details: { + reason: error.reason, + requestedCount: turns, + undoableCount: error.available, + }, + }, + ); + } + + private removedFromTurnId(turns: number): number | undefined { + if (!this.agentState.has(turnKey)) return undefined; + const anchorTurnIds = this.agentState.get(turnKey).anchorTurnIds; + if (anchorTurnIds.length < turns) return undefined; + const totalAnchors = computeUndoCut(this.context.get(), Number.MAX_SAFE_INTEGER).removedCount; + if (totalAnchors !== anchorTurnIds.length) return undefined; + return anchorTurnIds[anchorTurnIds.length - turns]; + } + + private busyError(reason: 'loop' | 'compaction'): Error2 { + const message = reason === 'loop' + ? 'Cannot undo while a turn is active or queued. Wait for it to finish, then retry.' + : 'Cannot undo while conversation compaction is running. Wait for it to finish, then retry.'; + return new Error2(ErrorCodes.SESSION_BUSY, message, { details: { reason } }); + } + + private assertUndoAvailable(turns: number): void { + const check = precheckUndo(this.context.get(), turns); + if (check.ok) return; + throw new Error2( + ErrorCodes.SESSION_UNDO_UNAVAILABLE, + formatUndoUnavailableMessage(check), + { + details: { + reason: check.reason, + requestedCount: check.requested, + undoableCount: check.undoable, + }, + }, + ); + } + + private async reconcileParticipants(phase?: 'after-flush'): Promise { + const participants = this.participants.list().filter((participant) => participant.phase === phase); + const results = await Promise.allSettled( + participants.map((participant) => participant.reconcileAfterUndo()), + ); + results.forEach((result, index) => { + if (result.status === 'fulfilled') return; + this.log.error('undo participant reconciliation failed', { + participantId: participants[index]?.id, + error: result.reason, + }); + }); + } + + private async reconcileLastPromptSafely(): Promise { + try { + await this.reconcileLastPrompt(); + } catch (error) { + this.log.error('undo lastPrompt reconciliation failed', { error }); + } + } + + private async flushAfterReconcile(): Promise { + try { + await this.dispatcher.flush(); + } catch (error) { + this.log.error('undo wire flush failed after in-memory commit', { + stage: 'state reconciliation', + error, + }); + throw error; + } + } + + private async reconcileLastPrompt(): Promise { + if (this.agentCtx.agentId !== MAIN_AGENT_ID) return; + const pending = this.loop.snapshot().queue.filter((item) => item.meta?.tracked === true).at(-1); + let lastPrompt = pending === undefined + ? undefined + : promptMetadataTextFromContentParts(pending.message.content, (pending.meta?.origin as UserPromptOrigin | undefined)?.clientMetadata); + if (lastPrompt === undefined) { + const history = this.context.get(); + for (let i = history.length - 1; i >= 0; i--) { + const message = history[i]!; + if (!isUndoAnchor(message)) continue; + lastPrompt = promptMetadataTextFromContentParts(message.content, message.origin?.kind === 'user' || message.origin?.kind === 'skill_activation' ? message.origin.clientMetadata : undefined); + if (lastPrompt !== undefined) break; + } + } + await this.metadata.update({ lastPrompt }); + this.eventService.publish( + new SessionMetaUpdated({ + payload: { + agentId: MAIN_AGENT_ID, + sessionId: this.session.sessionId, + patch: { lastPrompt }, + }, + }), + ); + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentConversationUndoService, + AgentConversationUndoService, + ScopeActivation.OnScopeCreated, + 'undo', +); diff --git a/packages/agent-core-v2/src/agent/usage/cacheProbe.ts b/packages/agent-core-v2/src/agent/usage/cacheProbe.ts new file mode 100644 index 0000000000000000000000000000000000000000..cd5906273f9341b45201affc3e83ab2d7c00e275 --- /dev/null +++ b/packages/agent-core-v2/src/agent/usage/cacheProbe.ts @@ -0,0 +1,8 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IAgentCacheProbeService { + readonly _serviceBrand: undefined; +} + +export const IAgentCacheProbeService: ServiceIdentifier = + createDecorator('agentCacheProbeService'); diff --git a/packages/agent-core-v2/src/agent/usage/cacheProbeService.ts b/packages/agent-core-v2/src/agent/usage/cacheProbeService.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff2cafb7015f4e44521e4c0da07256966e7a1bf7 --- /dev/null +++ b/packages/agent-core-v2/src/agent/usage/cacheProbeService.ts @@ -0,0 +1,59 @@ +import { Service } from '#/_base/di/service'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { inputTotal } from '#human/llm/usage'; +import { IModelCatalog } from '#/llm-adapter/model/catalog'; +import { ISessionUsageService } from '#/session/usage/sessionUsage'; + +import { IAgentCacheProbeService } from './cacheProbe'; +import { type UsageRecordedContext } from './usage'; + +export class AgentCacheProbeService extends Service implements IAgentCacheProbeService { + declare readonly _serviceBrand: undefined; + + constructor( + @ISessionUsageService usage: ISessionUsageService, + @IAgentScopeContext scopeContext: IAgentScopeContext, + @ITelemetryService private readonly telemetry: ITelemetryService, + @IModelCatalog private readonly models: IModelCatalog, + ) { + super(); + if (scopeContext.forkedFrom === undefined) return; + this._register( + usage.onDidRecord((e) => { + if (e.agent.agentId === scopeContext.agentId) this.probe(e); + }), + ); + } + + private probe(e: UsageRecordedContext): void { + if (!e.firstRecord || e.source?.type !== 'turn') return; + let providerType: string | undefined; + let protocol: string | undefined; + try { + const model = this.models.get(e.model); + providerType = model.providerType ?? model.protocol; + protocol = model.protocol; + } catch { } + this.telemetry.track2('prompt_cache_probe', { + source: 'fork', + turn_id: e.source.turnId, + provider_type: providerType, + protocol, + input_tokens: inputTotal(e.usage), + input_cache_read: e.usage.inputCacheRead, + input_cache_creation: e.usage.inputCacheCreation, + output_tokens: e.usage.output, + }); + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentCacheProbeService, + AgentCacheProbeService, + ScopeActivation.OnScopeCreated, + 'cacheProbe', +); diff --git a/packages/agent-core-v2/src/agent/usage/errors.ts b/packages/agent-core-v2/src/agent/usage/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..e9717eaa34956d889f109e415c08c11bede91536 --- /dev/null +++ b/packages/agent-core-v2/src/agent/usage/errors.ts @@ -0,0 +1,9 @@ +import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; + +export const UsageErrors = { + codes: { + TURN_ID_CONFLICT: 'usage.turn_id_conflict', + }, +} as const satisfies ErrorDomain; + +registerErrorDomain(UsageErrors); diff --git a/packages/agent-core-v2/src/agent/usage/usage.ts b/packages/agent-core-v2/src/agent/usage/usage.ts new file mode 100644 index 0000000000000000000000000000000000000000..c40e8ed54fce970fb95e2dc1b8dbcce8068ab8f5 --- /dev/null +++ b/packages/agent-core-v2/src/agent/usage/usage.ts @@ -0,0 +1,33 @@ +import type { AgentContext } from '#/agent/agentContext/agentContext'; +import type { AgentLLMRequestSource } from '#/agent/llmRequester/llmRequester'; +import type { TokenUsage } from '#human/llm/usage'; + +import { type ErrorCode } from '#/errors'; +import { Error2 } from '#/_base/errors/errors'; + +import { UsageErrors } from './errors'; + +export { UsageErrors } from './errors'; + +export type UsageErrorCode = (typeof UsageErrors.codes)[keyof typeof UsageErrors.codes]; + +export class UsageError extends Error2 { + constructor(code: UsageErrorCode, message: string, details?: Record) { + super(code as ErrorCode, message, { details }); + this.name = 'UsageError'; + } +} + +export interface UsageStatus { + readonly byModel?: Record; + readonly total?: TokenUsage; + readonly currentTurn?: TokenUsage; +} + +export interface UsageRecordedContext { + readonly agent: AgentContext; + readonly model: string; + readonly usage: Readonly; + readonly source?: AgentLLMRequestSource; + readonly firstRecord: boolean; +} diff --git a/packages/agent-core-v2/src/agent/usage/usageEvents.ts b/packages/agent-core-v2/src/agent/usage/usageEvents.ts new file mode 100644 index 0000000000000000000000000000000000000000..441c1b609dc148f831f7a71d022dffb013b32d2a --- /dev/null +++ b/packages/agent-core-v2/src/agent/usage/usageEvents.ts @@ -0,0 +1,92 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import type { TurnEndReason } from '#/agent/loop/turnEvents'; +import type { PermissionMode } from '#/agent/permissionPolicy/types'; +import { AgentEvent2 } from '#/app/event/event2'; + +import type { UsageStatus } from './usage'; + +export interface AgentStatusUpdatedPayload { + readonly agentId: string; + usage?: UsageStatus; + swarmMode?: boolean; + towerMode?: boolean; + planMode?: boolean; + model?: string; + thinkingEffort?: string; + maxContextTokens?: number; + contextTokens?: number; +} + +export class AgentStatusUpdated extends AgentEvent2 { + static override readonly type = 'agent.status.updated'; + static override readonly observable = true; +} +export interface AgentStatusUpdated extends AgentStatusUpdatedPayload {} + +export type AgentPhase = + | { readonly kind: 'idle' } + | { + readonly kind: 'running'; + readonly turnId: number; + readonly step: number; + readonly stepId: string; + readonly since: number; + } + | { + readonly kind: 'tool_call'; + readonly turnId: number; + readonly step: number; + readonly toolCallId: string; + readonly name: string; + readonly since: number; + } + | { + readonly kind: 'retrying'; + readonly turnId: number; + readonly step: number; + readonly stepId: string; + readonly failedAttempt: number; + readonly nextAttempt: number; + readonly maxAttempts: number; + readonly delayMs: number; + readonly errorName?: string; + readonly statusCode?: number; + readonly since: number; + } + | { + readonly kind: 'awaiting_approval'; + readonly turnId: number; + readonly step?: number; + readonly approval?: unknown; + readonly since: number; + } + | { + readonly kind: 'interrupted'; + readonly turnId: number; + readonly step?: number; + readonly reason: 'aborted' | 'max_steps' | 'error'; + readonly message?: string; + readonly at: number; + } + | { + readonly kind: 'ended'; + readonly turnId: number; + readonly reason: TurnEndReason; + readonly durationMs?: number; + readonly at: number; + }; + +export interface AgentStatusUpdatedEvent { + readonly type: 'agent.status.updated'; + readonly model?: string; + readonly thinkingEffort?: string; + readonly contextTokens?: number; + readonly maxContextTokens?: number; + readonly contextUsage?: number; + readonly planMode?: boolean; + readonly swarmMode?: boolean; + readonly towerMode?: boolean; + readonly permission?: PermissionMode; + readonly usage?: UsageStatus; + readonly phase?: AgentPhase; +} diff --git a/packages/agent-core-v2/src/agent/usage/usageOps.ts b/packages/agent-core-v2/src/agent/usage/usageOps.ts new file mode 100644 index 0000000000000000000000000000000000000000..7b8b1316642d5bcc4a5b287c6550b490e67476a3 --- /dev/null +++ b/packages/agent-core-v2/src/agent/usage/usageOps.ts @@ -0,0 +1,34 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { z } from 'zod'; + +import { AgentEvent2 } from '#/app/event/event2'; +import { type TokenUsage } from '#human/llm/usage'; + +export type UsageRecordScope = 'session' | 'turn'; + +export interface UsageModelState { + readonly byModel: Record; +} + +const usageRecordSchema = z.object({ + agentId: z.string(), + model: z.string(), + usage: z.custom(), + usageScope: z.custom().optional(), +}); + +export class UsageRecord extends AgentEvent2> { + static override readonly type = 'usage.record'; + static override readonly durable = true; + static override readonly schema = usageRecordSchema; +} +export interface UsageRecord { + readonly agentId: string; + readonly model: string; + readonly usage: TokenUsage; + readonly usageScope?: UsageRecordScope; +} + +export function copyUsage(usage: TokenUsage): TokenUsage { + return { ...usage }; +} diff --git a/packages/agent-core-v2/src/agent/userTool/userTool.ts b/packages/agent-core-v2/src/agent/userTool/userTool.ts new file mode 100644 index 0000000000000000000000000000000000000000..d16d17a305f3602c868f9ccc3a9a06f0d2bd7376 --- /dev/null +++ b/packages/agent-core-v2/src/agent/userTool/userTool.ts @@ -0,0 +1,23 @@ +import { createDecorator } from '#/_base/di/instantiation'; +import type { ToolDisclosure } from '#/tool/toolContract'; + +export interface UserToolRegistration { + readonly name: string; + readonly description: string; + readonly parameters: Record; + readonly disclosure?: ToolDisclosure; +} + +export interface IAgentUserToolService { + readonly _serviceBrand: undefined; + + list(): readonly UserToolRegistration[]; + inheritUserTools( + parent: IAgentUserToolService, + activeToolNames?: readonly string[], + ): void; + register(input: UserToolRegistration): void; + unregister(name: string): void; +} + +export const IAgentUserToolService = createDecorator('agentUserToolService'); diff --git a/packages/agent-core-v2/src/agent/userTool/userToolOps.ts b/packages/agent-core-v2/src/agent/userTool/userToolOps.ts new file mode 100644 index 0000000000000000000000000000000000000000..7fb6e991bd5ba7c11f923a43bb440cb31269818c --- /dev/null +++ b/packages/agent-core-v2/src/agent/userTool/userToolOps.ts @@ -0,0 +1,73 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { original } from 'immer'; +import { z } from 'zod'; + +import { AgentEvent2 } from '#/app/event/event2'; +import { defineState } from '#/state/state'; + +import type { UserToolRegistration } from './userTool'; + +export type UserToolModelState = Map; + +const toolsRegisterUserToolSchema = z.object({ + agentId: z.string(), + name: z.string(), + description: z.string(), + parameters: z.custom(), + disclosure: z.custom().optional(), +}); + +export class ToolsRegisterUserTool extends AgentEvent2< + z.infer +> { + static override readonly type = 'tools.register_user_tool'; + static override readonly durable = true; + static override readonly schema = toolsRegisterUserToolSchema; +} +export interface ToolsRegisterUserTool extends UserToolRegistration { + readonly agentId: string; +} + +const toolsUnregisterUserToolSchema = z.object({ + agentId: z.string(), + name: z.string(), +}); + +export class ToolsUnregisterUserTool extends AgentEvent2< + z.infer +> { + static override readonly type = 'tools.unregister_user_tool'; + static override readonly durable = true; + static override readonly schema = toolsUnregisterUserToolSchema; +} +export interface ToolsUnregisterUserTool { + readonly agentId: string; + readonly name: string; +} + +function equalRegistration(a: UserToolRegistration, b: UserToolRegistration): boolean { + return ( + a.name === b.name && + a.description === b.description && + a.parameters === b.parameters && + a.disclosure === b.disclosure + ); +} + +export const userToolKey = defineState('userTool', (): UserToolModelState => new Map()).replayable({ + schema: z.custom(), +}) + .on(ToolsRegisterUserTool, (s, e) => { + const existing = s.get(e.name); + if (existing !== undefined && equalRegistration(original(existing), e)) return; + s.set(e.name, { + name: e.name, + description: e.description, + parameters: e.parameters, + disclosure: e.disclosure, + }); + }) + .on(ToolsUnregisterUserTool, (s, e) => { + if (!s.has(e.name)) return; + s.delete(e.name); + }); diff --git a/packages/agent-core-v2/src/agent/userTool/userToolService.ts b/packages/agent-core-v2/src/agent/userTool/userToolService.ts new file mode 100644 index 0000000000000000000000000000000000000000..3abaf3fae2593169d29ba978501760c447fb6a7a --- /dev/null +++ b/packages/agent-core-v2/src/agent/userTool/userToolService.ts @@ -0,0 +1,179 @@ +import { randomUUID } from 'node:crypto'; + +import { type IDisposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { abortable } from '#/_base/utils/abort'; +import { IAgentProfileService } from '#/agent/profile/profile'; +import type { + ExecutableTool, + ExecutableToolContext, + ExecutableToolResult, +} from '#/tool/toolContract'; +import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { + INTERACTION_TAG_AGENT_ID, + INTERACTION_TAG_SESSION_ID, + INTERACTION_TAG_TOOL_CALL_ID, + INTERACTION_TAG_TURN_ID, + type InteractionTags, +} from '#/human/interaction/interaction'; +import { interactions } from '#/human/interaction/facade'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { IEventDispatcher } from '#/state/eventDispatcher'; + +import { IAgentUserToolService, type UserToolRegistration } from './userTool'; +import { + ToolsRegisterUserTool, + ToolsUnregisterUserTool, + userToolKey, +} from './userToolOps'; + +interface UserToolExecutionRequest { + readonly turnId?: number; + readonly toolCallId: string; + readonly name: string; + readonly args: unknown; +} + +export class AgentUserToolService extends Service implements IAgentUserToolService { + declare readonly _serviceBrand: undefined; + + private readonly registrations = new Map(); + + constructor( + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @IAgentToolRegistryService private readonly registry: IAgentToolRegistryService, + @IAgentProfileService private readonly profile: IAgentProfileService, + @ISessionContext private readonly session: ISessionContext, + @IEventDispatcher private readonly dispatcher: IEventDispatcher, + @IAgentStateService private readonly agentState: IAgentStateService, + ) { + super(); + this.agentState.contributeState(userToolKey); + this._register( + this.dispatcher.hooks.onDidRestore.register('user-tool', async (_ctx, next) => { + this.restoreRegisteredTools(); + await next(); + }), + ); + } + + list(): readonly UserToolRegistration[] { + return [...this.agentState.get(userToolKey).values()]; + } + + inheritUserTools( + parent: IAgentUserToolService, + activeToolNames?: readonly string[], + ): void { + for (const registration of parent.list()) { + void this.dispatcher.dispatch( + new ToolsRegisterUserTool({ ...registration, agentId: this.scopeContext.agentId }), + ); + const activate = + activeToolNames === undefined || activeToolNames.includes(registration.name); + this.applyRegister(registration, { activate }); + } + } + + register(input: UserToolRegistration): void { + void this.dispatcher.dispatch( + new ToolsRegisterUserTool({ ...input, agentId: this.scopeContext.agentId }), + ); + this.applyRegister(input); + } + + unregister(name: string): void { + void this.dispatcher.dispatch( + new ToolsUnregisterUserTool({ agentId: this.scopeContext.agentId, name }), + ); + this.applyUnregister(name); + } + + private restoreRegisteredTools(): void { + const persistedActive = this.profile.getActiveToolNames(); + for (const registration of this.agentState.get(userToolKey).values()) { + const activate = + persistedActive === undefined || persistedActive.includes(registration.name); + this.applyRegister(registration, { activate }); + } + } + + private applyRegister(input: UserToolRegistration, options?: { readonly activate?: boolean }): void { + const { name, description, parameters } = input; + this.applyUnregister(name); + const tool: ExecutableTool = { + name, + description, + parameters, + resolveExecution: (args) => ({ + approvalRule: name, + execute: (context) => this.executeUserTool(context, name, args), + }), + }; + this.registrations.set( + name, + this._register( + this.registry.register(tool, { source: 'user', disclosure: input.disclosure }), + ), + ); + if (options?.activate === false) return; + this.profile.addActiveTool(name); + } + + private applyUnregister(name: string): void { + const registration = this.registrations.get(name); + if (registration === undefined) return; + registration.dispose(); + this.registrations.delete(name); + this.profile.removeActiveTool(name); + } + + private async executeUserTool( + context: ExecutableToolContext, + name: string, + args: unknown, + ): Promise { + const id = `user_tool_${randomUUID()}`; + const tags: InteractionTags = { + [INTERACTION_TAG_AGENT_ID]: this.scopeContext.agentId, + [INTERACTION_TAG_SESSION_ID]: this.session.sessionId, + [INTERACTION_TAG_TOOL_CALL_ID]: context.toolCallId, + }; + if (context.turnId !== undefined) tags[INTERACTION_TAG_TURN_ID] = context.turnId; + const request = interactions.request({ + id, + kind: 'user_tool', + payload: { + turnId: context.turnId, + toolCallId: context.toolCallId, + name, + args, + }, + tags, + }); + try { + return await abortable(request, context.signal); + } catch (error) { + if (context.signal.aborted) { + interactions.respond(id, { + output: `User tool "${name}" was aborted.`, + isError: true, + }); + } + throw error; + } + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentUserToolService, + AgentUserToolService, + ScopeActivation.OnScopeCreated, + 'userTool', +); diff --git a/packages/agent-core-v2/src/app/agentIdentity/agentIdentity.ts b/packages/agent-core-v2/src/app/agentIdentity/agentIdentity.ts new file mode 100644 index 0000000000000000000000000000000000000000..f84a99fa946d6cb99637d84726d7a8d9654edc45 --- /dev/null +++ b/packages/agent-core-v2/src/app/agentIdentity/agentIdentity.ts @@ -0,0 +1,72 @@ +import { replaceUserAgentProduct } from '@moonshot-ai/kimi-code-oauth'; + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export const DEFAULT_IDENTITY_SLUG = 'agent'; + +export interface AgentIdentitySnapshot { + readonly displayName: string | undefined; + readonly slug: string | undefined; + readonly outboundUserAgent: string; + readonly thirdPartyUserAgent: string | undefined; + readonly requestHeaders: Readonly>; +} + +export interface IAgentIdentity { + readonly _serviceBrand: undefined; + + resolved(): Promise; + current(): AgentIdentitySnapshot; +} + +export const IAgentIdentity: ServiceIdentifier = + createDecorator('agentIdentity'); + +export function normalizeIdentitySlug(raw: string): string { + const folded = raw + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, '-') + .replaceAll(/^-+|-+$/g, ''); + return folded.length > 0 ? folded : DEFAULT_IDENTITY_SLUG; +} + +export interface AgentIdentityInput { + readonly name?: string; + readonly slug?: string; + readonly hostDisplayName?: string; + readonly hostRequestHeaders: Readonly>; +} + +export function buildAgentIdentitySnapshot(input: AgentIdentityInput): AgentIdentitySnapshot { + const name = declared(input.name); + const rawSlug = declared(input.slug) ?? name; + const slug = rawSlug === undefined ? undefined : normalizeIdentitySlug(rawSlug); + const userAgentKeys = Object.keys(input.hostRequestHeaders).filter( + (key) => key.toLowerCase() === 'user-agent', + ); + const hostUserAgent = + userAgentKeys[0] === undefined ? undefined : input.hostRequestHeaders[userAgentKeys[0]]; + const thirdPartyUserAgent = + hostUserAgent === undefined || slug === undefined + ? hostUserAgent + : replaceUserAgentProduct(hostUserAgent, slug); + const requestHeaders: Record = { ...input.hostRequestHeaders }; + if (slug !== undefined) { + for (const key of userAgentKeys) { + const value = requestHeaders[key]; + if (value !== undefined) requestHeaders[key] = replaceUserAgentProduct(value, slug); + } + } + return { + displayName: name ?? declared(input.hostDisplayName), + slug, + outboundUserAgent: thirdPartyUserAgent ?? slug ?? DEFAULT_IDENTITY_SLUG, + thirdPartyUserAgent, + requestHeaders, + }; +} + +function declared(raw: string | undefined): string | undefined { + const trimmed = raw?.trim(); + return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; +} diff --git a/packages/agent-core-v2/src/app/agentIdentity/agentIdentityService.ts b/packages/agent-core-v2/src/app/agentIdentity/agentIdentityService.ts new file mode 100644 index 0000000000000000000000000000000000000000..9926e194365671e186579fde7bc95a4867866a62 --- /dev/null +++ b/packages/agent-core-v2/src/app/agentIdentity/agentIdentityService.ts @@ -0,0 +1,60 @@ +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { CoreErrors } from '#/_base/errors/codes'; +import { Error2 } from '#/_base/errors/errors'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigService } from '#/app/config/config'; + +import { + buildAgentIdentitySnapshot, + IAgentIdentity, + type AgentIdentitySnapshot, +} from './agentIdentity'; +import { IDENTITY_SECTION, type IdentityConfig } from './configSection'; + +export class AgentIdentityService implements IAgentIdentity { + declare readonly _serviceBrand: undefined; + + private snapshot: AgentIdentitySnapshot | undefined; + private readonly frozen: Promise; + + constructor( + @IConfigService config: IConfigService, + @IBootstrapService bootstrap: IBootstrapService, + ) { + this.frozen = config.ready + .catch(() => undefined) + .then(() => { + const section = config.get(IDENTITY_SECTION) ?? {}; + this.snapshot = buildAgentIdentitySnapshot({ + name: section.name, + slug: section.slug, + hostDisplayName: bootstrap.args.displayName, + hostRequestHeaders: bootstrap.args.requestHeaders, + }); + return this.snapshot; + }); + } + + resolved(): Promise { + return this.frozen; + } + + current(): AgentIdentitySnapshot { + if (this.snapshot === undefined) { + throw new Error2( + CoreErrors.codes.INTERNAL, + 'agent identity read before config load completed', + ); + } + return this.snapshot; + } +} + +registerScopedService( + LifecycleScope.App, + IAgentIdentity, + AgentIdentityService, + ScopeActivation.OnScopeCreated, + 'agentIdentity', +); diff --git a/packages/agent-core-v2/src/app/agentIdentity/configSection.ts b/packages/agent-core-v2/src/app/agentIdentity/configSection.ts new file mode 100644 index 0000000000000000000000000000000000000000..6011727ebc5fde594d2d3c6557cfb091fa0c79cd --- /dev/null +++ b/packages/agent-core-v2/src/app/agentIdentity/configSection.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; + +import { + type EnvBindings, + envBindings, + stripEnvBoundFields, +} from '#/app/config/config'; +import { registerConfigSection } from '#/app/config/configSectionContributions'; + +export const IDENTITY_SECTION = 'identity'; + +export const IdentityConfigSchema = z.object({ + name: z.string().optional(), + slug: z.string().optional(), +}); + +export type IdentityConfig = z.infer; + +export const IDENTITY_NAME_ENV = 'KIMI_CODE_IDENTITY_NAME'; +export const IDENTITY_SLUG_ENV = 'KIMI_CODE_IDENTITY_SLUG'; + +function parseIdentityEnv(raw: string): string | undefined { + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +export const identityEnvBindings: EnvBindings = envBindings( + IdentityConfigSchema, + { + name: { env: IDENTITY_NAME_ENV, parse: parseIdentityEnv }, + slug: { env: IDENTITY_SLUG_ENV, parse: parseIdentityEnv }, + }, +); + +export const stripIdentityEnv = stripEnvBoundFields(identityEnvBindings); + +registerConfigSection(IDENTITY_SECTION, IdentityConfigSchema, { + defaultValue: {}, + env: identityEnvBindings, + stripEnv: stripIdentityEnv, +}); diff --git a/packages/agent-core-v2/src/app/auth/auth.ts b/packages/agent-core-v2/src/app/auth/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..fadef5139de7e5f15b57faa3ff2304ae5d18b693 --- /dev/null +++ b/packages/agent-core-v2/src/app/auth/auth.ts @@ -0,0 +1,130 @@ +import type { + AuthManagedUsageResult, + AuthManagedUserInfoResult, + BearerTokenProvider, + KimiOAuthLoginOptions, + KimiOAuthLoginResult, + KimiOAuthLogoutResult, + KimiOAuthTokenRef, + KimiRegion, +} from '@moonshot-ai/kimi-code-oauth'; +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { Error2 } from '#/_base/errors/errors'; + +import type { OAuthRef } from '#/llm-adapter/provider/provider'; + +import { AuthErrors } from './errors'; +import type { + OAuthFlowSnapshot, + OAuthFlowStart, + OAuthLoginCancelResponse, + OAuthLogoutResponse, + RefreshOAuthProviderModelsResponse, +} from './oauthProtocol'; + +export interface AuthStatus { + readonly loggedIn: boolean; + readonly provider?: string; +} + +export interface OAuthLoginOptions { + readonly region?: KimiRegion; +} + +export interface IOAuthService { + readonly _serviceBrand: undefined; + + startLogin(provider?: string, options?: OAuthLoginOptions): Promise; + getFlow(provider?: string): OAuthFlowSnapshot | undefined; + cancelLogin(provider?: string): Promise; + logout(provider?: string): Promise; + status(provider?: string): Promise; + refreshOAuthProviderModels(): Promise; + getManagedUsage(provider?: string): Promise; + getManagedUserInfo(provider?: string): Promise; + resolveTokenProvider(provider: string, oauthRef?: OAuthRef): BearerTokenProvider | undefined; + getCachedAccessToken(provider: string, oauthRef?: OAuthRef): Promise; + getRegion(): KimiRegion; +} + +export const IOAuthService: ServiceIdentifier = + createDecorator('oauthService'); + +export interface IOAuthToolkit { + readonly _serviceBrand: undefined; + + login(providerName?: string, options?: KimiOAuthLoginOptions): Promise; + logout(providerName?: string, oauthRef?: KimiOAuthTokenRef): Promise; + getCachedAccessToken( + providerName?: string, + oauthRef?: KimiOAuthTokenRef, + ): Promise; + tokenProvider(providerName?: string, oauthRef?: KimiOAuthTokenRef): BearerTokenProvider; + getManagedUsage( + providerName?: string, + options?: { readonly oauthRef?: KimiOAuthTokenRef; readonly baseUrl?: string }, + ): Promise; + getManagedUserInfo( + providerName?: string, + options?: { readonly oauthRef?: KimiOAuthTokenRef; readonly baseUrl?: string }, + ): Promise; +} + +export const IOAuthToolkit: ServiceIdentifier = + createDecorator('oauthToolkit'); + +export interface IAuthSummaryService { + readonly _serviceBrand: undefined; + + summarize(): Promise; + ensureReady(modelOverride?: string): Promise; +} + +export const IAuthSummaryService: ServiceIdentifier = + createDecorator('authSummaryService'); + +export class AuthProvisioningRequiredError extends Error2 { + constructor() { + super( + AuthErrors.codes.AUTH_PROVISIONING_REQUIRED, + 'no provider configured; complete onboarding via /login or the providers endpoint', + { name: 'AuthProvisioningRequiredError' }, + ); + } +} + +export class AuthTokenMissingError extends Error2 { + readonly providerId: string; + + constructor(providerId: string) { + super( + AuthErrors.codes.AUTH_TOKEN_MISSING, + `provider ${providerId} has no credential configured`, + { details: { provider_id: providerId }, name: 'AuthTokenMissingError' }, + ); + this.providerId = providerId; + } +} + +export class AuthModelNotResolvedError extends Error2 { + readonly modelId: string | undefined; + readonly providerId: string | undefined; + + constructor(modelId: string | undefined, providerId?: string) { + const details: Record = {}; + if (modelId !== undefined) details['model_id'] = modelId; + if (providerId !== undefined) details['provider_id'] = providerId; + super( + AuthErrors.codes.AUTH_MODEL_NOT_RESOLVED, + modelId === undefined + ? 'no default model configured' + : `model ${modelId} does not resolve to a configured provider`, + { + details: Object.keys(details).length === 0 ? undefined : details, + name: 'AuthModelNotResolvedError', + }, + ); + this.modelId = modelId; + this.providerId = providerId; + } +} diff --git a/packages/agent-core-v2/src/app/auth/authService.ts b/packages/agent-core-v2/src/app/auth/authService.ts new file mode 100644 index 0000000000000000000000000000000000000000..820a7279b40d63d9b3fc972b81370023d2193e2c --- /dev/null +++ b/packages/agent-core-v2/src/app/auth/authService.ts @@ -0,0 +1,950 @@ +import { randomUUID } from 'node:crypto'; + +import { + DeviceCodeTimeoutError, + KIMI_CODE_PLATFORM_ID, + KIMI_CODE_PROVIDER_NAME, + KimiOAuthToolkit, + kimiCodeBaseUrl, + kimiRegionLoginHosts, + OAuthError, + applyManagedKimiCodeConfig, + clearManagedKimiCodeConfig, + fetchManagedKimiCodeModels, + resolveKimiCodeLoginAuth, + resolveKimiCodeOAuthRef, + resolveKimiCodeRuntimeAuth, + resolveKimiRegion, + type AuthManagedUserInfoResult, + type AuthManagedUsageResult, + type BearerTokenProvider, + type DeviceAuthorization, + type KimiRegion, + type ManagedKimiConfigShape, +} from '@moonshot-ai/kimi-code-oauth'; +import type { + OAuthFlowSnapshot, + OAuthFlowStart, + OAuthFlowStartPending, + OAuthFlowStatus, + OAuthLoginCancelResponse, + OAuthLogoutResponse, + RefreshOAuthProviderModelsResponse, +} from './oauthProtocol'; + +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Error2, ErrorCodes } from '#/errors'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigService } from '#/app/config/config'; +import { IEventService } from '#/app/event/event'; +import { ILogService } from '#/_base/log/log'; +import { + effectiveModelConfig, + nonEmpty, + resolveModelAuthMaterial, + resolveModelForReady, + providerNameFromFlatModel, + type ModelReadyFailureReason, +} from '#/llm-adapter/model/model-auth'; +import { IModelService, type ModelRecord } from '#/llm-adapter/model/model'; +import { + DEFAULT_MODEL_SECTION, + MODELS_SECTION, + PROVIDERS_SECTION, + THINKING_SECTION, +} from '#/app/kosongConfig/configSection'; +import { ModelCatalogChanged } from '#/app/kosongConfig/discovery'; +import { + IProviderService, + type OAuthRef, + type ProviderConfig, + type ProvidersChangedEvent, +} from '#/llm-adapter/provider/provider'; +import { isOAuthCatalogVendor } from '#/llm-adapter/provider/provider-definition'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; + +import { + AuthModelNotResolvedError, + AuthProvisioningRequiredError, + AuthTokenMissingError, + type AuthStatus, + IAuthSummaryService, + IOAuthService, + IOAuthToolkit, + type OAuthLoginOptions, +} from './auth'; + +const TERMINAL_RETENTION_MS = 5 * 60 * 1000; +const DEFAULT_DEVICE_EXPIRES_IN_SEC = 15 * 60; +const SERVICES_SECTION = 'services'; + +type TerminalOAuthFlowStatus = Exclude; + +interface FlowState { + readonly flowId: string; + readonly provider: string; + readonly controller: AbortController; + readonly oauthRef: OAuthRef | undefined; + readonly loginBaseUrl: string | undefined; + readonly startedAt: number; + device: DeviceAuthorization | undefined; + status: OAuthFlowStatus; + tokenGranted: boolean; + expiresAt: number; + gcTimer: ReturnType | undefined; + errorMessage: string | undefined; + resolvedAt: string | undefined; +} + +export class OAuthService extends Disposable implements IOAuthService { + declare readonly _serviceBrand: undefined; + private readonly flows = new Map(); + + private refreshChain: Promise = Promise.resolve(); + + constructor( + @IOAuthToolkit private readonly toolkit: IOAuthToolkit, + @IProviderService private readonly providerService: IProviderService, + @IConfigService private readonly config: IConfigService, + @ITelemetryService private readonly telemetry: ITelemetryService, + @ILogService private readonly log: ILogService, + @IEventService private readonly events: IEventService, + @IBootstrapService private readonly bootstrap: IBootstrapService, + ) { + super(); + this._register(providerService.onDidChangeProviders((event) => { + this.invalidateFlows(event); + })); + } + + async startLogin( + provider = KIMI_CODE_PROVIDER_NAME, + options: OAuthLoginOptions = {}, + ): Promise { + this.log.info('oauth startLogin: enter', { provider }); + const loginAuth = this.resolveLoginAuth(provider, options.region); + this.log.info('oauth startLogin: resolved login auth', { + provider, + hasOAuthRef: loginAuth.oauthRef !== undefined, + hasBaseUrl: loginAuth.baseUrl !== undefined, + hasOAuthHost: loginAuth.oauthHost !== undefined, + }); + this.abortExisting(provider); + + const state: FlowState = { + flowId: `oauth_${randomUUID()}`, + provider, + controller: new AbortController(), + oauthRef: loginAuth.oauthRef, + loginBaseUrl: loginAuth.baseUrl, + startedAt: Date.now(), + device: undefined, + status: 'pending', + tokenGranted: false, + expiresAt: Date.now() + DEFAULT_DEVICE_EXPIRES_IN_SEC * 1000, + gcTimer: undefined, + errorMessage: undefined, + resolvedAt: undefined, + }; + this.flows.set(provider, state); + + let resolveDevice!: (auth: DeviceAuthorization) => void; + let rejectDevice!: (error: unknown) => void; + const deviceReady = new Promise((resolve, reject) => { + resolveDevice = resolve; + rejectDevice = reject; + }); + + this.log.info('oauth startLogin: calling toolkit.login', { provider }); + const loginPromise = this.toolkit.login(provider, { + signal: state.controller.signal, + oauthRef: loginAuth.oauthRef, + baseUrl: loginAuth.baseUrl, + oauthHost: loginAuth.oauthHost, + onDeviceCode: (auth) => { + this.log.info('oauth startLogin: onDeviceCode fired', { provider }); + state.device = auth; + if (auth.expiresIn !== null) { + state.expiresAt = Date.now() + auth.expiresIn * 1000; + } + resolveDevice(auth); + }, + }); + const fastPath: Promise = loginPromise.then(async () => { + if (state.device !== undefined) return undefined; + this.log.info('oauth startLogin: toolkit resolved without device code (already authenticated)', { + provider, + }); + await this.completeAlreadyAuthenticatedLogin(state); + return { + flow_id: state.flowId, + provider: state.provider, + status: 'authenticated', + }; + }); + + loginPromise.then( + () => { + this.log.info('oauth startLogin: toolkit.login resolved', { + provider, + deviceArrived: state.device !== undefined, + }); + if (state.device !== undefined) { + this.handleSuccess(state); + } + }, + (error) => { + this.log.warn('oauth startLogin: toolkit.login rejected', { + provider, + error: error instanceof Error ? error.message : String(error), + }); + this.handleFailure(state, error); + rejectDevice(error); + }, + ); + + this.log.info('oauth startLogin: awaiting device flow start', { provider }); + const winner = await Promise.race([ + deviceReady.then((device) => ({ kind: 'device' as const, device })), + fastPath.then((result) => ({ kind: 'fast' as const, result })), + ]); + if (winner.kind === 'fast' && winner.result !== undefined) { + this.log.info('oauth startLogin: fast path returned authenticated', { provider }); + return winner.result; + } + const device = winner.kind === 'device' ? winner.device : await deviceReady; + this.log.info('oauth startLogin: deviceReady resolved', { provider }); + return this.toFlowStart(state, device); + } + + getFlow(provider = KIMI_CODE_PROVIDER_NAME): OAuthFlowSnapshot | undefined { + const state = this.flows.get(provider); + if (state === undefined || state.device === undefined) return undefined; + return this.toSnapshot(state, state.device); + } + + cancelLogin(provider = KIMI_CODE_PROVIDER_NAME): Promise { + const state = this.flows.get(provider); + if (state === undefined || state.status !== 'pending') { + return Promise.resolve({ cancelled: false, status: state?.status ?? 'cancelled' }); + } + state.controller.abort(); + this.setTerminal(state, 'cancelled'); + return Promise.resolve({ cancelled: true, status: 'cancelled' }); + } + + async logout(provider = KIMI_CODE_PROVIDER_NAME): Promise { + const oauthRef = + provider === KIMI_CODE_PROVIDER_NAME + ? this.resolveRuntimeOAuthRef(provider) + : this.readOAuthRefOptional(provider); + const result = await this.toolkit.logout(provider, oauthRef); + this.abortExisting(provider); + await this.deprovisionProvider(provider); + return { logged_out: true, provider: result.providerName }; + } + + async status(provider = KIMI_CODE_PROVIDER_NAME): Promise { + this.log.info('oauth status: enter', { provider }); + const oauthRef = this.readOAuthRefOptional(provider); + try { + const token = await this.getCachedAccessToken(provider, oauthRef); + this.log.info('oauth status: got token', { provider, hasToken: token !== undefined }); + return token === undefined ? { loggedIn: false } : { loggedIn: true, provider }; + } catch (error) { + this.log.warn('oauth status: getCachedAccessToken threw', { + provider, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + resolveTokenProvider(provider: string, oauthRef?: OAuthRef): BearerTokenProvider | undefined { + return this.toolkit.tokenProvider(provider, this.resolveRuntimeOAuthRef(provider, oauthRef)); + } + + getCachedAccessToken(provider: string, oauthRef?: OAuthRef): Promise { + return this.toolkit.getCachedAccessToken(provider, this.resolveRuntimeOAuthRef(provider, oauthRef)); + } + + getManagedUsage(provider = KIMI_CODE_PROVIDER_NAME): Promise { + const configured = this.providerService.get(provider); + const auth = resolveKimiCodeRuntimeAuth({ + configuredBaseUrl: configured?.baseUrl, + configuredOAuthRef: configured?.oauth, + }); + return this.toolkit.getManagedUsage(provider, { + oauthRef: auth.oauthRef, + baseUrl: auth.baseUrl, + }); + } + + getManagedUserInfo(provider = KIMI_CODE_PROVIDER_NAME): Promise { + const configured = this.providerService.get(provider); + const auth = resolveKimiCodeRuntimeAuth({ + configuredBaseUrl: configured?.baseUrl, + configuredOAuthRef: configured?.oauth, + }); + return this.toolkit.getManagedUserInfo(provider, { + oauthRef: auth.oauthRef, + baseUrl: auth.baseUrl, + }); + } + + refreshOAuthProviderModels(): Promise { + const run = this.refreshChain.then(() => this.doRefreshOAuthProviderModels()); + this.refreshChain = run.then( + () => undefined, + () => undefined, + ); + return run; + } + + private async doRefreshOAuthProviderModels(): Promise { + const changed: RefreshOAuthProviderModelsResponse['changed'] = []; + const unchanged: string[] = []; + const failed: RefreshOAuthProviderModelsResponse['failed'] = []; + const finish = (): RefreshOAuthProviderModelsResponse => { + this.telemetry.track2('oauth_models_refresh_finished', { + changed_count: changed.length, + unchanged_count: unchanged.length, + failed_count: failed.length, + }); + return { changed, unchanged, failed }; + }; + + try { + await this.config.reload(); + } catch (error) { + failed.push({ + provider: KIMI_CODE_PROVIDER_NAME, + reason: error instanceof Error ? error.message : String(error), + }); + finish(); + throw error; + } + const current = this.readUserConfigShape(); + const provider = current.providers[KIMI_CODE_PROVIDER_NAME]; + if (!isOAuthCatalogProvider(provider)) { + return finish(); + } + + try { + const auth = resolveKimiCodeRuntimeAuth({ + configuredBaseUrl: provider.baseUrl, + configuredOAuthRef: provider.oauth, + }); + const tokenProvider = this.resolveTokenProvider(KIMI_CODE_PROVIDER_NAME, auth.oauthRef); + if (tokenProvider === undefined) { + throw new Error2(ErrorCodes.AUTH_TOKEN_MISSING, 'OAuth token provider is not configured.', { + details: { provider_id: KIMI_CODE_PROVIDER_NAME }, + }); + } + const token = await tokenProvider.getAccessToken(); + const models = await fetchManagedKimiCodeModels({ + accessToken: token, + baseUrl: auth.baseUrl, + }); + if (models.length === 0) { + return finish(); + } + + await this.config.reload(); + const fresh = this.readUserConfigShape(); + const freshProvider = fresh.providers[KIMI_CODE_PROVIDER_NAME]; + if (!isOAuthCatalogProvider(freshProvider)) { + return finish(); + } + if ( + freshProvider.baseUrl !== provider.baseUrl || + freshProvider.oauth.storage !== provider.oauth.storage || + freshProvider.oauth.key !== provider.oauth.key || + freshProvider.oauth.oauthHost !== provider.oauth.oauthHost + ) { + return finish(); + } + + const next = structuredClone(fresh); + applyManagedKimiCodeConfig(next, { + models, + baseUrl: auth.baseUrl, + oauthKey: auth.oauthRef.key, + oauthHost: auth.oauthRef.oauthHost, + preserveDefaultModel: true, + }); + const refreshedAliasKeys = providerRefreshAliasKeys( + fresh, + next, + KIMI_CODE_PROVIDER_NAME, + `${KIMI_CODE_PLATFORM_ID}/`, + ); + restoreProviderAliases( + next, + preserveUserProviderAliases(fresh, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys), + ); + restoreDefaultSelection(next, fresh.defaultModel, fresh.thinking?.enabled); + clampDanglingDefault(next); + + if (providerModelsEqual(fresh, next, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys)) { + unchanged.push(KIMI_CODE_PROVIDER_NAME); + } else { + const { added, removed } = computeChanges( + collectModelIdsForAliases(fresh, refreshedAliasKeys), + collectModelIdsForAliases(next, refreshedAliasKeys), + ); + await this.config.replace(PROVIDERS_SECTION, next.providers); + await this.config.replace(MODELS_SECTION, next.models ?? {}); + await this.config.replace(DEFAULT_MODEL_SECTION, next.defaultModel); + await this.config.replace(THINKING_SECTION, next.thinking); + changed.push({ + provider_id: KIMI_CODE_PROVIDER_NAME, + provider_name: 'Kimi Code', + added, + removed, + }); + } + } catch (error) { + failed.push({ + provider: KIMI_CODE_PROVIDER_NAME, + reason: error instanceof Error ? error.message : String(error), + }); + } + + const result = finish(); + if (result.changed.length > 0) { + this.events.publish(new ModelCatalogChanged({ payload: result })); + } + return result; + } + + private readUserConfigShape(): ManagedKimiConfigShape { + const providers = + this.config.inspect>(PROVIDERS_SECTION).userValue ?? {}; + const models = this.config.inspect>(MODELS_SECTION).userValue ?? {}; + const services = + this.config.inspect(SERVICES_SECTION).userValue; + const defaultModel = this.config.inspect(DEFAULT_MODEL_SECTION).userValue; + const thinking = + this.config.inspect(THINKING_SECTION).userValue; + return { + providers: { ...providers } as ManagedKimiConfigShape['providers'], + models: { ...models } as ManagedKimiConfigShape['models'], + services: services === undefined ? undefined : { ...services }, + defaultModel, + thinking: thinking === undefined ? undefined : { ...thinking }, + }; + } + + getRegion(): KimiRegion { + const oauth = this.providerService.get(KIMI_CODE_PROVIDER_NAME)?.oauth; + return resolveKimiRegion({ + configuredOAuthHost: oauth?.oauthHost, + configuredOAuthKey: oauth?.key, + readMarker: + (this.bootstrap.getEnv('KIMI_CODE_REGION_MARKER') ?? + process.env['KIMI_CODE_REGION_MARKER']) !== 'off', + homeDir: this.bootstrap.homeDir, + }); + } + + private resolveLoginAuth( + provider: string, + region?: KimiRegion, + ): { + readonly oauthRef: OAuthRef | undefined; + readonly baseUrl: string | undefined; + readonly oauthHost: string | undefined; + } { + const config = this.providerService.get(provider); + if (provider !== KIMI_CODE_PROVIDER_NAME) { + return { oauthRef: config?.oauth, baseUrl: undefined, oauthHost: undefined }; + } + const hosts = region === undefined ? undefined : kimiRegionLoginHosts(region); + const loginAuth = resolveKimiCodeLoginAuth({ + configuredBaseUrl: config?.baseUrl, + configuredOAuthRef: config?.oauth, + requestedBaseUrl: hosts?.baseUrl, + requestedOAuthHost: hosts?.oauthHost, + }); + const oauthRef = + loginAuth.oauthRef ?? + resolveKimiCodeOAuthRef({ + oauthHost: loginAuth.oauthHost, + baseUrl: loginAuth.baseUrl, + }); + return { + oauthRef, + baseUrl: loginAuth.baseUrl, + oauthHost: loginAuth.oauthHost, + }; + } + + private readOAuthRefOptional(provider: string): OAuthRef | undefined { + return this.providerService.get(provider)?.oauth; + } + + private resolveRuntimeOAuthRef(provider: string, oauthRef?: OAuthRef): OAuthRef | undefined { + if (provider !== KIMI_CODE_PROVIDER_NAME) return oauthRef; + const config = this.providerService.get(provider); + return resolveKimiCodeRuntimeAuth({ + configuredBaseUrl: config?.baseUrl, + configuredOAuthRef: oauthRef ?? config?.oauth, + }).oauthRef; + } + + private abortExisting(provider: string): void { + const existing = this.flows.get(provider); + if (existing !== undefined && existing.status === 'pending') { + existing.controller.abort(); + this.setTerminal(existing, 'cancelled'); + } + } + + private invalidateFlows(event: ProvidersChangedEvent): void { + const affected = new Set([...event.removed, ...event.changed]); + if (affected.size === 0) return; + for (const state of this.flows.values()) { + if (!affected.has(state.provider)) continue; + if (state.status !== 'pending') continue; + if (state.tokenGranted) continue; + state.controller.abort(); + state.errorMessage = 'Provider configuration changed during login.'; + this.setTerminal(state, 'cancelled'); + } + } + + private handleSuccess(state: FlowState): void { + if (state.status !== 'pending') return; + state.tokenGranted = true; + void this.provisionAfterSuccess(state); + } + + private async completeAlreadyAuthenticatedLogin(state: FlowState): Promise { + if (state.status !== 'pending') return; + state.tokenGranted = true; + await this.provisionAfterSuccess(state); + } + + private async provisionAfterSuccess(state: FlowState): Promise { + try { + await this.provisionProvider(state.provider, state.oauthRef, state.loginBaseUrl); + if (this.flows.get(state.provider) !== state) return; + if (state.provider === KIMI_CODE_PROVIDER_NAME) { + await this.refreshOAuthProviderModelsBestEffort(state.provider); + } + } catch (error) { + this.log.warn('oauth provider provisioning failed', { + provider: state.provider, + error: error instanceof Error ? error.message : String(error), + }); + } finally { + if (state.status === 'pending') { + this.setTerminal(state, 'authenticated'); + } + } + } + + private async provisionProvider( + provider: string, + oauthRef: OAuthRef | undefined, + loginBaseUrl: string | undefined, + ): Promise { + if (oauthRef === undefined && provider !== KIMI_CODE_PROVIDER_NAME) return; + const baseUrl = + loginBaseUrl ?? this.providerService.get(provider)?.baseUrl ?? kimiCodeBaseUrl(); + await this.providerService.set(provider, { + type: 'kimi', + baseUrl, + apiKey: '', + oauth: oauthRef, + }); + } + + private async refreshOAuthProviderModelsBestEffort(provider: string): Promise { + const result = await this.refreshOAuthProviderModels(); + if (result.failed.length > 0) { + this.log.warn('oauth startLogin: model refresh failed on already-authenticated fast path', { + provider, + failures: result.failed, + }); + } + } + + private async deprovisionProvider(provider: string): Promise { + if (provider !== KIMI_CODE_PROVIDER_NAME) return; + const next = structuredClone(this.readUserConfigShape()); + const cleanup = clearManagedKimiCodeConfig(next); + if ( + !cleanup.removedProvider && + cleanup.removedModels.length === 0 && + !cleanup.defaultModelCleared && + cleanup.removedServices.length === 0 + ) { + return; + } + if (cleanup.defaultModelCleared) { + next.thinking = undefined; + } + if (cleanup.removedProvider) { + await this.config.replace(PROVIDERS_SECTION, next.providers); + } + if (cleanup.removedModels.length > 0) { + await this.config.replace(MODELS_SECTION, next.models ?? {}); + } + if (cleanup.removedServices.length > 0) { + await this.config.replace(SERVICES_SECTION, next.services); + } + if (cleanup.defaultModelCleared) { + await this.config.replace(DEFAULT_MODEL_SECTION, undefined); + await this.config.replace(THINKING_SECTION, undefined); + } + } + + private handleFailure(state: FlowState, err: unknown): void { + if (state.status !== 'pending') return; + state.errorMessage = err instanceof Error ? err.message : String(err); + this.setTerminal(state, classifyFailure(err)); + } + + private setTerminal(state: FlowState, status: TerminalOAuthFlowStatus): void { + state.status = status; + state.resolvedAt = new Date().toISOString(); + this.telemetry.track2('oauth_login_finished', { + provider: state.provider, + status, + duration_ms: Date.now() - state.startedAt, + }); + const timer = setTimeout(() => { + if (this.flows.get(state.provider) === state) { + this.flows.delete(state.provider); + } + }, TERMINAL_RETENTION_MS); + timer.unref(); + state.gcTimer = timer; + } + + private toFlowStart(state: FlowState, device: DeviceAuthorization): OAuthFlowStartPending { + const expiresIn = device.expiresIn ?? DEFAULT_DEVICE_EXPIRES_IN_SEC; + return { + flow_id: state.flowId, + provider: state.provider, + verification_uri: device.verificationUri, + verification_uri_complete: device.verificationUriComplete, + user_code: device.userCode, + expires_in: expiresIn, + interval: device.interval, + status: 'pending', + expires_at: new Date(state.expiresAt).toISOString(), + }; + } + + private toSnapshot(state: FlowState, device: DeviceAuthorization): OAuthFlowSnapshot { + return { + ...this.toFlowStart(state, device), + status: state.status, + resolved_at: state.resolvedAt, + error_message: state.errorMessage, + }; + } +} + +export class AuthSummaryService implements IAuthSummaryService { + declare readonly _serviceBrand: undefined; + + constructor( + @IProviderService private readonly providerService: IProviderService, + @IModelService private readonly modelService: IModelService, + @IConfigService private readonly config: IConfigService, + @IOAuthService private readonly oauth: IOAuthService, + @ITelemetryService private readonly telemetry: ITelemetryService, + @ILogService private readonly log: ILogService, + ) {} + + async summarize(): Promise { + const providers = this.providerService.list(); + const oauthProviders = Object.entries(providers).filter( + ([, config]) => config.oauth !== undefined, + ); + this.log.info('auth summarize: enter', { + total: Object.keys(providers).length, + oauthProviders: oauthProviders.map(([name]) => name), + }); + const statuses: AuthStatus[] = []; + for (const [name] of oauthProviders) { + try { + statuses.push(await this.oauth.status(name)); + } catch (error) { + this.log.warn('auth summarize: status threw', { + provider: name, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return statuses; + } + + async ensureReady(modelOverride?: string): Promise { + try { + await this.config.reload(); + const providers = this.providerService.list(); + const models = this.modelService.list(); + const modelId = modelOverride ?? this.modelService.getDefaultModel(); + const configured = modelId === undefined || modelId === '' ? undefined : models[modelId]; + if (Object.keys(providers).length === 0 && !isProviderlessModel(configured)) { + throw new AuthProvisioningRequiredError(); + } + const resolution = resolveModelForReady(modelId, models, providers, this.providerService.getDefaultProvider()); + if (!resolution.resolved) { + throw unresolvedModelError(modelId, resolution.reason, configured); + } + + const model = effectiveModelConfig(configured as ModelRecord); + const providerId = model.providerId ?? model.provider ?? this.providerService.getDefaultProvider(); + const provider = providerId === undefined ? undefined : this.providerService.get(providerId); + const providerName = (providerId ?? providerNameFromFlatModel(model)) as string; + + const auth = resolveModelAuthMaterial({ + modelId: modelId as string, + model, + provider, + providerName, + }); + if (auth.apiKey !== undefined) return; + if (auth.oauth !== undefined) { + const providerKey = auth.oauthProviderKey ?? providerName; + const token = await this.oauth.getCachedAccessToken(providerKey, auth.oauth); + if (nonEmpty(token) !== undefined) return; + throw new AuthTokenMissingError(providerKey); + } + throw new AuthTokenMissingError(providerName); + } catch (error) { + this.telemetry.track2('auth_ensure_ready_failed', { + reason: ensureReadyFailureReason(error) ?? 'unexpected', + has_model_override: modelOverride !== undefined, + }); + throw error; + } + } +} + +function unresolvedModelError( + modelId: string | undefined, + reason: ModelReadyFailureReason, + configured: ModelRecord | undefined, +): AuthModelNotResolvedError { + if (reason === 'no-default') { + return new AuthModelNotResolvedError(undefined); + } + if (reason === 'provider-missing' && configured !== undefined) { + const model = effectiveModelConfig(configured); + return new AuthModelNotResolvedError(modelId, model.providerId ?? model.provider); + } + return new AuthModelNotResolvedError(modelId); +} + +function ensureReadyFailureReason( + error: unknown, +): 'provisioning_required' | 'model_not_resolved' | 'token_missing' | undefined { + if (error instanceof AuthProvisioningRequiredError) return 'provisioning_required'; + if (error instanceof AuthModelNotResolvedError) return 'model_not_resolved'; + if (error instanceof AuthTokenMissingError) return 'token_missing'; + return undefined; +} + +function classifyFailure(err: unknown): TerminalOAuthFlowStatus { + if (err instanceof DeviceCodeTimeoutError) return 'expired'; + if (err instanceof OAuthError) { + return err.message.toLowerCase().includes('aborted') ? 'cancelled' : 'denied'; + } + return 'denied'; +} + +function isProviderlessModel(model: ModelRecord | undefined): boolean { + if (model === undefined) return false; + const effective = effectiveModelConfig(model); + return ( + effective.providerId === undefined && + effective.provider === undefined && + providerNameFromFlatModel(effective) !== undefined + ); +} + +interface ManagedModel { + readonly provider: string; + readonly model: string; + readonly maxContextSize: number; + readonly capabilities?: readonly string[]; + readonly displayName?: string; +} + +function isOAuthCatalogProvider( + provider: ProviderConfig | Record | undefined, +): provider is ProviderConfig & { oauth: OAuthRef } { + const type = (provider as ProviderConfig | undefined)?.type; + return ( + provider !== undefined && + isOAuthCatalogVendor(type) && + (provider as ProviderConfig).oauth !== undefined + ); +} + +function collectModelIdsForAliases( + config: ManagedKimiConfigShape, + aliasKeys: ReadonlySet, +): Set { + const ids = new Set(); + for (const aliasKey of aliasKeys) { + const alias = managedModel(config, aliasKey); + if (alias !== undefined && alias.model.length > 0) ids.add(alias.model); + } + return ids; +} + +function providerAliasKeys(config: ManagedKimiConfigShape, providerId: string): Set { + const keys = new Set(); + for (const [alias, model] of Object.entries(config.models ?? {})) { + if ((model as ManagedModel).provider === providerId) keys.add(alias); + } + return keys; +} + +function generatedProviderAliasKeys( + config: ManagedKimiConfigShape, + providerId: string, + aliasPrefix: string, +): Set { + const keys = new Set(); + for (const [alias, model] of Object.entries(config.models ?? {})) { + if ((model as ManagedModel).provider === providerId && alias.startsWith(aliasPrefix)) { + keys.add(alias); + } + } + return keys; +} + +function computeChanges( + oldIds: Set, + newIds: Set, +): { added: number; removed: number } { + let added = 0; + for (const id of newIds) { + if (!oldIds.has(id)) added++; + } + let removed = 0; + for (const id of oldIds) { + if (!newIds.has(id)) removed++; + } + return { added, removed }; +} + +function providerModelsEqual( + config: ManagedKimiConfigShape, + nextConfig: ManagedKimiConfigShape, + providerId: string, + aliasKeys: ReadonlySet, +): boolean { + return ( + providerModelSnapshot(config, providerId, aliasKeys) === + providerModelSnapshot(nextConfig, providerId, aliasKeys) + ); +} + +function providerModelSnapshot( + config: ManagedKimiConfigShape, + providerId: string, + aliasKeys: ReadonlySet, +): string { + const snapshots: Array<{ alias: string; model: ManagedModel }> = []; + for (const alias of aliasKeys) { + const model = managedModel(config, alias); + if (model === undefined || model.provider !== providerId) continue; + snapshots.push({ + alias, + model: { + ...model, + capabilities: + model.capabilities === undefined ? undefined : model.capabilities.toSorted(), + }, + }); + } + snapshots.sort((a, b) => a.alias.localeCompare(b.alias)); + return JSON.stringify({ defaultModel: config.defaultModel ?? null, models: snapshots }); +} + +function providerRefreshAliasKeys( + config: ManagedKimiConfigShape, + nextConfig: ManagedKimiConfigShape, + providerId: string, + aliasPrefix: string, +): Set { + const keys = generatedProviderAliasKeys(config, providerId, aliasPrefix); + for (const key of providerAliasKeys(nextConfig, providerId)) keys.add(key); + return keys; +} + +function preserveUserProviderAliases( + config: ManagedKimiConfigShape, + providerId: string, + refreshedAliasKeys: ReadonlySet, +): Record { + const preserved: Record = {}; + for (const [alias, model] of Object.entries(config.models ?? {})) { + const entry = model as ManagedModel; + if (entry.provider !== providerId || refreshedAliasKeys.has(alias)) continue; + preserved[alias] = structuredClone(entry); + } + return preserved; +} + +function restoreProviderAliases( + config: ManagedKimiConfigShape, + aliases: Record, +): void { + if (Object.keys(aliases).length === 0) return; + config.models = { + ...config.models, + ...aliases, + } as ManagedKimiConfigShape['models']; +} + +function restoreDefaultSelection( + config: ManagedKimiConfigShape, + defaultModel: string | undefined, + defaultEnabled: boolean | undefined, +): void { + if (defaultModel === undefined || config.models?.[defaultModel] === undefined) return; + config.defaultModel = defaultModel; + const capabilities = managedModel(config, defaultModel)?.capabilities ?? []; + const enabled = capabilities.includes('always_thinking') ? true : defaultEnabled; + if (enabled !== undefined) { + config.thinking = { ...config.thinking, enabled }; + } +} + +function clampDanglingDefault(config: ManagedKimiConfigShape): void { + if (config.defaultModel !== undefined && config.models?.[config.defaultModel] === undefined) { + config.defaultModel = undefined; + config.thinking = undefined; + } +} + +function managedModel( + config: ManagedKimiConfigShape, + alias: string, +): ManagedModel | undefined { + return config.models?.[alias] as ManagedModel | undefined; +} + +class OAuthToolkitService extends KimiOAuthToolkit implements IOAuthToolkit { + declare readonly _serviceBrand: undefined; + constructor(@IBootstrapService bootstrap: IBootstrapService) { + super({ homeDir: bootstrap.homeDir, identity: bootstrap.clientIdentity }); + } +} + +registerScopedService(LifecycleScope.App, IOAuthService, OAuthService, ScopeActivation.OnScopeCreated, 'auth'); +registerScopedService(LifecycleScope.App, IOAuthToolkit, OAuthToolkitService, ScopeActivation.OnScopeCreated, 'auth'); +registerScopedService(LifecycleScope.App, IAuthSummaryService, AuthSummaryService, ScopeActivation.OnScopeCreated, 'auth'); diff --git a/packages/agent-core-v2/src/app/auth/configSection.ts b/packages/agent-core-v2/src/app/auth/configSection.ts new file mode 100644 index 0000000000000000000000000000000000000000..0ac2dd6b8345281f0f18d294af3ff34ab140a93f --- /dev/null +++ b/packages/agent-core-v2/src/app/auth/configSection.ts @@ -0,0 +1,212 @@ +import { z } from 'zod'; + +import { + type ConfigEffectiveOverlay, + type ConfigStripEnv, + type EnvBindings, + envBindings, + stripEnvBoundFields, +} from '#/app/config/config'; +import { registerConfigOverlay } from '#/app/config/configOverlayContributions'; +import { registerConfigSection } from '#/app/config/configSectionContributions'; +import { + camelToSnake, + cloneRecord, + isPlainObject, + plainObjectToToml, + setDefined, + snakeToCamel, + transformPlainObject, +} from '#/app/config/toml'; +import { type AssertExact, type Equal } from '#/_base/utils/typeEquality'; +import type { OAuthRef } from '#/llm-adapter/provider/provider'; + +export const SERVICES_SECTION = 'services'; + +const StringRecordSchema = z.record(z.string(), z.string()); + +const OAuthRefSchema = z.object({ + storage: z.enum(['file', 'keyring']), + key: z.string().min(1), + oauthHost: z.string().min(1).optional(), +}); + +type _AssertOAuthRef = AssertExact, OAuthRef>>; + +export const MoonshotServiceConfigSchema = z.object({ + baseUrl: z.string().optional(), + apiKey: z.string().optional(), + oauth: OAuthRefSchema.optional(), + customHeaders: StringRecordSchema.optional(), +}); + +export type MoonshotServiceConfig = z.infer; + +export const ServicesConfigSchema = z + .object({ + moonshotSearch: MoonshotServiceConfigSchema.optional(), + moonshotFetch: MoonshotServiceConfigSchema.optional(), + }) + .passthrough(); + +export type ServicesConfig = z.infer; + +export const WEB_SEARCH_BASE_URL_ENV = 'KIMI_WEB_SEARCH_BASE_URL'; +export const WEB_SEARCH_API_KEY_ENV = 'KIMI_WEB_SEARCH_API_KEY'; +export const WEB_FETCH_BASE_URL_ENV = 'KIMI_WEB_FETCH_BASE_URL'; +export const WEB_FETCH_API_KEY_ENV = 'KIMI_WEB_FETCH_API_KEY'; + +const nonBlankEnv = (raw: string): string | undefined => { + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : undefined; +}; + +const moonshotSearchEnvBindings = envBindings(MoonshotServiceConfigSchema, { + baseUrl: { env: WEB_SEARCH_BASE_URL_ENV, parse: nonBlankEnv }, + apiKey: { env: WEB_SEARCH_API_KEY_ENV, parse: nonBlankEnv }, +}); + +const moonshotFetchEnvBindings = envBindings(MoonshotServiceConfigSchema, { + baseUrl: { env: WEB_FETCH_BASE_URL_ENV, parse: nonBlankEnv }, + apiKey: { env: WEB_FETCH_API_KEY_ENV, parse: nonBlankEnv }, +}); + +export const servicesEnvBindings: EnvBindings = envBindings( + ServicesConfigSchema, + { + moonshotSearch: moonshotSearchEnvBindings, + moonshotFetch: moonshotFetchEnvBindings, + }, +); + +const servicesCredentialEnvOverlay: ConfigEffectiveOverlay = { + apply(effective, getEnv, validate) { + const services = effective[SERVICES_SECTION]; + if (!isPlainObject(services)) return []; + const moonshotSearch = isolateEnvServiceCredentials( + services['moonshotSearch'], + getEnv, + WEB_SEARCH_BASE_URL_ENV, + WEB_SEARCH_API_KEY_ENV, + ); + const moonshotFetch = isolateEnvServiceCredentials( + services['moonshotFetch'], + getEnv, + WEB_FETCH_BASE_URL_ENV, + WEB_FETCH_API_KEY_ENV, + ); + if ( + moonshotSearch === services['moonshotSearch'] && + moonshotFetch === services['moonshotFetch'] + ) { + return []; + } + effective[SERVICES_SECTION] = validate(SERVICES_SECTION, { + ...services, + moonshotSearch, + moonshotFetch, + }); + return [SERVICES_SECTION]; + }, +}; + +function isolateEnvServiceCredentials( + service: unknown, + getEnv: (name: string) => string | undefined, + baseUrlEnv: string, + apiKeyEnv: string, +): unknown { + const baseUrl = nonBlankEnv(getEnv(baseUrlEnv) ?? ''); + const apiKey = nonBlankEnv(getEnv(apiKeyEnv) ?? ''); + if (baseUrl !== undefined) return { baseUrl, apiKey }; + if (apiKey === undefined) return service; + if (!isPlainObject(service)) return { apiKey }; + const { apiKey: _apiKey, oauth: _oauth, ...rest } = service; + return { ...rest, apiKey }; +} + +const stripMoonshotSearchEnv = stripEnvBoundFields(moonshotSearchEnvBindings); +const stripMoonshotFetchEnv = stripEnvBoundFields(moonshotFetchEnvBindings); + +export const stripServicesEnv: ConfigStripEnv = (value, raw, getEnv) => { + if (!isPlainObject(value)) return value; + let out: ServicesConfig | undefined; + for (const [key, strip] of [ + ['moonshotSearch', stripMoonshotSearchEnv], + ['moonshotFetch', stripMoonshotFetchEnv], + ] as const) { + const entry = value[key]; + if (entry === undefined) continue; + const stripped = strip(entry, isPlainObject(raw) ? raw[key] : undefined, getEnv); + if (stripped === entry) continue; + out ??= { ...value }; + if (stripped === undefined) { + delete out[key]; + } else { + out[key] = stripped; + } + } + return out ?? value; +}; + +export const servicesFromToml = (rawSnake: unknown): unknown => { + if (!isPlainObject(rawSnake)) return rawSnake; + const out: Record = {}; + for (const [name, entry] of Object.entries(rawSnake)) { + out[snakeToCamel(name)] = isPlainObject(entry) ? serviceEntryFromToml(entry) : entry; + } + return out; +}; + +function serviceEntryFromToml(data: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(data)) { + const targetKey = snakeToCamel(key); + if (targetKey === 'oauth') { + out[targetKey] = isPlainObject(value) ? transformPlainObject(value) : value; + } else if (targetKey === 'customHeaders') { + out[targetKey] = isPlainObject(value) ? cloneRecord(value) : value; + } else { + out[targetKey] = value; + } + } + return out; +} + +export const servicesToToml = (value: unknown, rawSnake: unknown): unknown => { + if (!isPlainObject(value)) return value; + const out = cloneRecord(rawSnake); + writeService(out, 'moonshot_search', value['moonshotSearch']); + writeService(out, 'moonshot_fetch', value['moonshotFetch']); + return out; +}; + +function writeService(out: Record, snakeKey: string, service: unknown): void { + if (isPlainObject(service)) { + out[snakeKey] = serviceEntryToToml(service); + } else { + delete out[snakeKey]; + } +} + +function serviceEntryToToml(service: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(service)) { + if (key === 'oauth' && isPlainObject(value)) { + out[camelToSnake(key)] = plainObjectToToml(value, undefined); + } else if (key === 'customHeaders' && value !== undefined) { + out[camelToSnake(key)] = cloneRecord(value); + } else { + setDefined(out, camelToSnake(key), value); + } + } + return out; +} + +registerConfigSection(SERVICES_SECTION, ServicesConfigSchema, { + fromToml: servicesFromToml, + toToml: servicesToToml, + env: servicesEnvBindings, + stripEnv: stripServicesEnv, +}); +registerConfigOverlay(servicesCredentialEnvOverlay); diff --git a/packages/agent-core-v2/src/app/auth/errors.ts b/packages/agent-core-v2/src/app/auth/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..331503db0bdb9e6109b432c25efbf925748d1a0d --- /dev/null +++ b/packages/agent-core-v2/src/app/auth/errors.ts @@ -0,0 +1,45 @@ +import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; + +export const AuthErrors = { + codes: { + AUTH_LOGIN_REQUIRED: 'auth.login_required', + AUTH_PROVISIONING_REQUIRED: 'auth.provisioning_required', + AUTH_TOKEN_MISSING: 'auth.token_missing', + AUTH_TOKEN_UNAUTHORIZED: 'auth.token_unauthorized', + AUTH_MODEL_NOT_RESOLVED: 'auth.model_not_resolved', + }, + info: { + 'auth.login_required': { + title: 'Login required', + retryable: false, + public: true, + action: 'Run /login to authenticate with the OAuth provider.', + }, + 'auth.provisioning_required': { + title: 'Provider provisioning required', + retryable: false, + public: true, + action: 'Configure a provider via /login or the providers endpoint.', + }, + 'auth.token_missing': { + title: 'Provider credential missing', + retryable: false, + public: true, + action: 'Configure an API key or complete OAuth login for the provider.', + }, + 'auth.token_unauthorized': { + title: 'Provider credential unauthorized', + retryable: false, + public: true, + action: 'Re-authenticate with the OAuth provider.', + }, + 'auth.model_not_resolved': { + title: 'Model not resolved', + retryable: false, + public: true, + action: 'Set a default model or configure the requested model alias.', + }, + }, +} as const satisfies ErrorDomain; + +registerErrorDomain(AuthErrors); diff --git a/packages/agent-core-v2/src/app/auth/oauthProtocol.ts b/packages/agent-core-v2/src/app/auth/oauthProtocol.ts new file mode 100644 index 0000000000000000000000000000000000000000..c65e0467f0c0525f6181f970c8a075c4df77d538 --- /dev/null +++ b/packages/agent-core-v2/src/app/auth/oauthProtocol.ts @@ -0,0 +1,99 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema } from '#/_base/utils/isoDateTime'; +import { kimiRegionSchema } from '@moonshot-ai/kimi-code-oauth'; + +export const oauthFlowStatusEnum = z.enum([ + 'pending', + 'authenticated', + 'denied', + 'expired', + 'cancelled', +]); +export type OAuthFlowStatus = z.infer; + +export const oauthFlowStartPendingSchema = z.object({ + flow_id: z.string().min(1), + provider: z.string().min(1), + status: z.literal('pending'), + verification_uri: z.string().url(), + verification_uri_complete: z.string().url(), + user_code: z.string().min(1), + expires_in: z.number().int().positive(), + interval: z.number().int().positive(), + expires_at: isoDateTimeSchema, +}); +export type OAuthFlowStartPending = z.infer; + +export const oauthFlowStartAuthenticatedSchema = z.object({ + flow_id: z.string().min(1), + provider: z.string().min(1), + status: z.literal('authenticated'), +}); +export type OAuthFlowStartAuthenticated = z.infer; + +export const oauthFlowStartSchema = z.discriminatedUnion('status', [ + oauthFlowStartPendingSchema, + oauthFlowStartAuthenticatedSchema, +]); +export type OAuthFlowStart = z.infer; + +export const oauthFlowSnapshotSchema = z.object({ + flow_id: z.string().min(1), + provider: z.string().min(1), + status: oauthFlowStatusEnum, + verification_uri: z.string().url(), + verification_uri_complete: z.string().url(), + user_code: z.string().min(1), + expires_in: z.number().int().positive(), + expires_at: isoDateTimeSchema, + interval: z.number().int().positive(), + resolved_at: isoDateTimeSchema.optional(), + error_message: z.string().optional(), +}); +export type OAuthFlowSnapshot = z.infer; + +export const oauthLoginCancelResponseSchema = z.object({ + cancelled: z.boolean(), + status: oauthFlowStatusEnum, +}); +export type OAuthLoginCancelResponse = z.infer; + +export const oauthLogoutResponseSchema = z.object({ + logged_out: z.literal(true), + provider: z.string().min(1), +}); +export type OAuthLogoutResponse = z.infer; + +export const oauthRegionResultSchema = z.object({ + region: kimiRegionSchema, +}); +export type OAuthRegionResult = z.infer; + +const providerRefreshChangeSchema = z.object({ + provider_id: z.string().min(1), + provider_name: z.string().min(1), + added: z.number().int().min(0), + removed: z.number().int().min(0), +}); + +const providerRefreshFailureSchema = z.object({ + provider: z.string().min(1), + reason: z.string().min(1), +}); + +export const refreshOAuthProviderModelsResponseSchema = z.object({ + changed: z.array(providerRefreshChangeSchema), + unchanged: z.array(z.string().min(1)), + failed: z.array(providerRefreshFailureSchema), +}); +export type RefreshOAuthProviderModelsResponse = z.infer< + typeof refreshOAuthProviderModelsResponseSchema +>; + +export { + managedUsageResultSchema, + type ManagedUsageResult, + managedUserInfoResultSchema, + type ManagedUserInfoResult, +} from '@moonshot-ai/kimi-code-oauth'; diff --git a/packages/agent-core-v2/src/app/authLegacy/authLegacy.ts b/packages/agent-core-v2/src/app/authLegacy/authLegacy.ts new file mode 100644 index 0000000000000000000000000000000000000000..321de8e9a49d287245cde3e111a86cb1fc8d0c51 --- /dev/null +++ b/packages/agent-core-v2/src/app/authLegacy/authLegacy.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export const managedProviderStatusSchema = z.enum([ + 'authenticated', + 'expired', + 'revoked', + 'unauthenticated', +]); +export type ManagedProviderStatus = z.infer; + +export const managedProviderSummarySchema = z.object({ + name: z.string().min(1), + status: managedProviderStatusSchema, +}); +export type ManagedProviderSummary = z.infer; + +export const authSummarySchema = z.object({ + models_ready: z.boolean(), + providers_count: z.number().int().nonnegative(), + managed_provider: managedProviderSummarySchema.nullable(), +}); +export type AuthSummary = z.infer; + +export interface IAuthLegacyService { + readonly _serviceBrand: undefined; + + get(): Promise; +} + +export const IAuthLegacyService: ServiceIdentifier = + createDecorator('authLegacyService'); diff --git a/packages/agent-core-v2/src/app/authLegacy/authLegacyService.ts b/packages/agent-core-v2/src/app/authLegacy/authLegacyService.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f16472277f4a8421caa5b5bb25f6915324a68bc --- /dev/null +++ b/packages/agent-core-v2/src/app/authLegacy/authLegacyService.ts @@ -0,0 +1,69 @@ +import { KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth'; +import type { AuthSummary } from './authLegacy'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IOAuthService } from '#/app/auth/auth'; +import { IConfigService } from '#/app/config/config'; +import { + DEFAULT_MODEL_SECTION, + DEFAULT_PROVIDER_SECTION, + MODELS_SECTION, + PROVIDERS_SECTION, +} from '#/app/kosongConfig/configSection'; +import { resolveModelForReady } from '#/llm-adapter/model/model-auth'; +import type { ModelRecord } from '#/llm-adapter/model/model'; +import type { ProviderConfig } from '#/llm-adapter/provider/provider'; + +import { IAuthLegacyService } from './authLegacy'; + +const MANAGED_PROVIDER_NAME = KIMI_CODE_PROVIDER_NAME; + +export class AuthLegacyService implements IAuthLegacyService { + declare readonly _serviceBrand: undefined; + + constructor( + @IConfigService private readonly config: IConfigService, + @IOAuthService private readonly oauth: IOAuthService, + ) {} + + async get(): Promise { + await this.config.ready; + + const snapshot = this.config.getAll(); + const providers = (snapshot[PROVIDERS_SECTION] ?? {}) as Readonly< + Record + >; + const models = (snapshot[MODELS_SECTION] ?? {}) as Readonly>; + const defaultModel = snapshot[DEFAULT_MODEL_SECTION] as string | undefined; + const defaultProvider = snapshot[DEFAULT_PROVIDER_SECTION] as string | undefined; + const providers_count = Object.keys(providers).length; + const models_ready = resolveModelForReady(defaultModel, models, providers, defaultProvider).resolved; + + let managed_provider: AuthSummary['managed_provider'] = null; + if (providers[MANAGED_PROVIDER_NAME] !== undefined) { + const loggedIn = await this.managedLoggedIn(); + managed_provider = { + name: MANAGED_PROVIDER_NAME, + status: loggedIn ? 'authenticated' : 'unauthenticated', + }; + } + + return { models_ready, providers_count, managed_provider }; + } + + private async managedLoggedIn(): Promise { + try { + return (await this.oauth.status(MANAGED_PROVIDER_NAME)).loggedIn; + } catch { + return false; + } + } +} + +registerScopedService( + LifecycleScope.App, + IAuthLegacyService, + AuthLegacyService, + ScopeActivation.OnScopeCreated, + 'authLegacy', +); diff --git a/packages/agent-core-v2/src/app/bashParser/bashParser.ts b/packages/agent-core-v2/src/app/bashParser/bashParser.ts new file mode 100644 index 0000000000000000000000000000000000000000..467c29f0619b767bcd41f8bda59ed111dbaa6430 --- /dev/null +++ b/packages/agent-core-v2/src/app/bashParser/bashParser.ts @@ -0,0 +1,28 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface BashSyntaxNode { + readonly type: string; + readonly text: string; + readonly startIndex: number; + readonly endIndex: number; + readonly isNamed: boolean; + readonly children: readonly BashSyntaxNode[]; +} + +export interface BashParseOptions { + readonly timeoutMs?: number; + readonly maxNodes?: number; +} + +export type BashParseResult = + | { readonly ok: true; readonly hasError: boolean; readonly root: BashSyntaxNode } + | { readonly ok: false; readonly reason: 'aborted' }; + +export interface IBashParserService { + readonly _serviceBrand: undefined; + + parse(source: string, options?: BashParseOptions): BashParseResult; +} + +export const IBashParserService: ServiceIdentifier = + createDecorator('bashParserService'); diff --git a/packages/agent-core-v2/src/app/bashParser/bashParserService.ts b/packages/agent-core-v2/src/app/bashParser/bashParserService.ts new file mode 100644 index 0000000000000000000000000000000000000000..a99bae49cb3bb6f4b5bad046df6c27bdf9768fab --- /dev/null +++ b/packages/agent-core-v2/src/app/bashParser/bashParserService.ts @@ -0,0 +1,62 @@ +import { parse } from '@moonshot-ai/tree-sitter-bash'; +import type { SyntaxNode } from '@moonshot-ai/tree-sitter-bash'; +import { LifecycleScope } from '#/app/scopes'; +import { registerScopedService, ScopeActivation } from '#/_base/di/scope'; + +import type { BashParseOptions, BashParseResult, BashSyntaxNode } from './bashParser'; +import { IBashParserService } from './bashParser'; + +interface MutableBashSyntaxNode { + type: string; + text: string; + startIndex: number; + endIndex: number; + isNamed: boolean; + children: BashSyntaxNode[]; +} + +function snapshot(root: SyntaxNode): BashSyntaxNode { + const order: SyntaxNode[] = []; + const stack: SyntaxNode[] = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + order.push(node); + for (const child of node.children) stack.push(child); + } + const dtos = new Map(); + for (const node of order) { + dtos.set(node, { + type: node.type, + text: node.text, + startIndex: node.startIndex, + endIndex: node.endIndex, + isNamed: node.isNamed, + children: [], + }); + } + for (const node of order) { + const dto = dtos.get(node)!; + for (const child of node.children) dto.children.push(dtos.get(child)!); + } + return dtos.get(root)!; +} + +export class BashParserService implements IBashParserService { + declare readonly _serviceBrand: undefined; + + parse(source: string, options: BashParseOptions = {}): BashParseResult { + const result = parse(source, options); + if (!result.ok) { + return { ok: false, reason: result.reason }; + } + return { ok: true, hasError: result.hasError, root: snapshot(result.rootNode) }; + } +} + +registerScopedService( + LifecycleScope.App, + IBashParserService, + BashParserService, + ScopeActivation.OnDemand, + 'bashParser', +); diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts new file mode 100644 index 0000000000000000000000000000000000000000..3e969174f868af9d13aff1077927d87fdcd7e62d --- /dev/null +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts @@ -0,0 +1,185 @@ +import { mkdirSync } from 'node:fs'; +import { homedir } from 'node:os'; + +import { join } from 'pathe'; + +import type { KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth'; + +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { createAppScope, type Scope, type ScopeSeed } from '#/_base/di/scope'; +import { + IFileSystemStorageService, +} from '#/persistence/interface/storage'; +import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; +import { FileSkillDiscovery } from '#/features/skill/catalog/fileSkillDiscovery'; +import { ISkillDiscovery } from '#/features/skill/catalog/skillDiscovery'; + +export type HostUiCapability = 'update_panel'; + +export interface HostArgs { + readonly agentFiles?: readonly string[]; + readonly skillDirs?: readonly string[]; + readonly requestHeaders: Readonly>; + readonly displayName?: string; + readonly replyStyleGuide?: string; + readonly nonInteractive?: boolean; + readonly uiCapabilities?: readonly HostUiCapability[]; +} + +export interface HostArgsInput { + readonly agentFiles?: readonly string[]; + readonly skillDirs?: readonly string[]; + readonly requestHeaders?: Readonly>; + readonly displayName?: string; + readonly replyStyleGuide?: string; + readonly nonInteractive?: boolean; + readonly uiCapabilities?: readonly HostUiCapability[]; +} + +export function resolveHostArgs(input: HostArgsInput | undefined): HostArgs { + return { + agentFiles: input?.agentFiles, + skillDirs: input?.skillDirs, + requestHeaders: input?.requestHeaders ?? {}, + displayName: input?.displayName, + replyStyleGuide: input?.replyStyleGuide, + nonInteractive: input?.nonInteractive, + uiCapabilities: input?.uiCapabilities, + }; +} + +export interface IBootstrapOptions { + readonly homeDir: string; + readonly configPath: string; + readonly osHomeDir: string; + readonly platform: NodeJS.Platform; + readonly arch: string; + readonly cwd: string; + readonly env: NodeJS.ProcessEnv; + readonly clientIdentity: KimiHostIdentity; + readonly args: HostArgs; +} + +export const IBootstrapOptions: ServiceIdentifier = + createDecorator('bootstrapOptions'); + +export type PersistenceScopeName = + | 'config' + | 'sessions' + | 'blobs' + | 'store' + | 'logs' + | 'cache' + | 'credentials'; + +export interface IBootstrapService { + readonly _serviceBrand: undefined; + + readonly platform: NodeJS.Platform; + readonly arch: string; + readonly cwd: string; + readonly osHomeDir: string; + readonly homeDir: string; + readonly configPath: string; + readonly clientIdentity: KimiHostIdentity; + readonly args: HostArgs; + readonly sessionsDir: string; + readonly blobsDir: string; + readonly storeDir: string; + readonly cacheDir: string; + readonly logsDir: string; + getEnv(name: string): string | undefined; + scope(name: PersistenceScopeName): string; + readonly configKey: string; +} + +export const IBootstrapService: ServiceIdentifier = + createDecorator('bootstrapService'); + +export interface BootstrapInput { + readonly homeDir?: string; + readonly configPath?: string; + readonly env?: NodeJS.ProcessEnv; + readonly osHomeDir?: string; + readonly platform?: NodeJS.Platform; + readonly arch?: string; + readonly cwd?: string; + readonly clientIdentity: KimiHostIdentity; + readonly args?: HostArgsInput; +} + +export function resolveBootstrapOptions(input: BootstrapInput): IBootstrapOptions { + const env = input.env ?? process.env; + const osHomeDir = input.osHomeDir ?? homedir(); + const homeDir = resolveKimiHome(input.homeDir, env, osHomeDir); + const configPath = input.configPath ?? join(homeDir, 'config.toml'); + return { + homeDir, + configPath, + osHomeDir, + platform: input.platform ?? process.platform, + arch: input.arch ?? process.arch, + cwd: input.cwd ?? process.cwd(), + env, + clientIdentity: input.clientIdentity, + args: resolveHostArgs(input.args), + }; +} + +export function bootstrapSeed(input: BootstrapInput): ScopeSeed { + return [ + [ + IBootstrapOptions as ServiceIdentifier, + resolveBootstrapOptions(input), + ], + ]; +} + +export interface BootstrapResult { + readonly app: Scope; +} + +export function bootstrap(input: BootstrapInput, extraSeeds: ScopeSeed = []): BootstrapResult { + const options = resolveBootstrapOptions(input); + const app = createAppScope({ + seeds: [...bootstrapSeed(input), ...storageSeed(options), ...skillSeed(), ...extraSeeds], + }); + return { app }; +} + +function storageSeed(options: IBootstrapOptions): ScopeSeed { + const file = (): SyncDescriptor => + new SyncDescriptor(FileStorageService, [options.homeDir, 0o700, 0o600]); + return [ + [IFileSystemStorageService as ServiceIdentifier, file()], + ]; +} + +function skillSeed(): ScopeSeed { + return [ + [ + ISkillDiscovery as ServiceIdentifier, + new SyncDescriptor(FileSkillDiscovery, []), + ], + ]; +} + +export function resolveKimiHome( + homeDir?: string, + env: NodeJS.ProcessEnv = process.env, + osHomeDir: string = homedir(), +): string { + return homeDir ?? env['KIMI_CODE_HOME'] ?? join(osHomeDir, '.kimi-code'); +} + +export function resolveConfigPath(input: { + readonly homeDir?: string; + readonly configPath?: string; +}): string { + return input.configPath ?? join(resolveKimiHome(input.homeDir), 'config.toml'); +} + +export function ensureKimiHome(homeDir: string): void { + mkdirSync(homeDir, { recursive: true, mode: 0o700 }); +} diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts new file mode 100644 index 0000000000000000000000000000000000000000..440a087cb692a18430cff941c84d2823dc6e899b --- /dev/null +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts @@ -0,0 +1,71 @@ +import { basename, join, relative } from 'pathe'; + +import type { KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; + +import { + IBootstrapOptions, + IBootstrapService, + type HostArgs, + type PersistenceScopeName, +} from './bootstrap'; + +export class BootstrapService implements IBootstrapService { + declare readonly _serviceBrand: undefined; + + readonly platform: NodeJS.Platform; + readonly arch: string; + readonly cwd: string; + readonly osHomeDir: string; + readonly homeDir: string; + readonly configPath: string; + readonly clientIdentity: KimiHostIdentity; + readonly args: HostArgs; + readonly sessionsDir: string; + readonly blobsDir: string; + readonly storeDir: string; + readonly cacheDir: string; + readonly logsDir: string; + readonly configKey: string; + + private readonly env: NodeJS.ProcessEnv; + private readonly scopes: Readonly>; + + constructor(@IBootstrapOptions options: IBootstrapOptions) { + this.platform = options.platform; + this.arch = options.arch; + this.cwd = options.cwd; + this.osHomeDir = options.osHomeDir; + this.env = options.env; + this.homeDir = options.homeDir; + this.configPath = options.configPath; + this.clientIdentity = options.clientIdentity; + this.args = options.args; + this.sessionsDir = join(options.homeDir, 'sessions'); + this.blobsDir = join(options.homeDir, 'blobs'); + this.storeDir = join(options.homeDir, 'store'); + this.cacheDir = join(options.homeDir, 'cache'); + this.logsDir = join(options.homeDir, 'logs'); + this.configKey = basename(options.configPath); + this.scopes = { + config: '', + sessions: relative(options.homeDir, join(options.homeDir, 'sessions')), + blobs: relative(options.homeDir, this.blobsDir), + store: relative(options.homeDir, this.storeDir), + logs: relative(options.homeDir, this.logsDir), + cache: relative(options.homeDir, this.cacheDir), + credentials: 'credentials', + }; + } + + getEnv(name: string): string | undefined { + return this.env[name]; + } + + scope(name: PersistenceScopeName): string { + return this.scopes[name]; + } +} + +registerScopedService(LifecycleScope.App, IBootstrapService, BootstrapService, ScopeActivation.OnScopeCreated, 'bootstrap'); diff --git a/packages/agent-core-v2/src/app/capability/capability.ts b/packages/agent-core-v2/src/app/capability/capability.ts new file mode 100644 index 0000000000000000000000000000000000000000..19d266cfc8561ef7df26f75552b71acb6b62d59e --- /dev/null +++ b/packages/agent-core-v2/src/app/capability/capability.ts @@ -0,0 +1,21 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { Event } from '#/_base/event'; + +import type { CapabilityDescriptor, CapabilityInstallChange, CapabilityStatus } from './types'; + +export interface ICapabilityService { + readonly _serviceBrand: undefined; + + readonly onDidChangeInstall: Event; + + describeCapabilities(): readonly CapabilityDescriptor[]; + + listCapabilities(): Promise; + + getCapability(id: string): Promise; + + installCapability(id: string): Promise; +} + +export const ICapabilityService: ServiceIdentifier = + createDecorator('capabilityService'); diff --git a/packages/agent-core-v2/src/app/capability/capabilityEvents.ts b/packages/agent-core-v2/src/app/capability/capabilityEvents.ts new file mode 100644 index 0000000000000000000000000000000000000000..e7b15722687b9867666b4cda58d167d9eef8a12b --- /dev/null +++ b/packages/agent-core-v2/src/app/capability/capabilityEvents.ts @@ -0,0 +1,22 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { Event2 } from '#/app/event/event2'; + +import type { CapabilityId, CapabilityInstallProgress } from './types'; + +export interface CapabilityChangedPayload { + readonly capability_id: CapabilityId; + readonly install: CapabilityInstallProgress; +} + +export class CapabilityChanged extends Event2<{ readonly payload: CapabilityChangedPayload }> { + static override readonly type = 'event.capability.changed'; +} +export interface CapabilityChanged { + readonly payload: CapabilityChangedPayload; +} + +export interface CapabilityChangedEvent { + readonly type: 'event.capability.changed'; + readonly capability_id: string; + readonly install: CapabilityInstallProgress; +} diff --git a/packages/agent-core-v2/src/app/capability/capabilityService.ts b/packages/agent-core-v2/src/app/capability/capabilityService.ts new file mode 100644 index 0000000000000000000000000000000000000000..fcf6117d1c52d0f4dca817a7203c99de039b7479 --- /dev/null +++ b/packages/agent-core-v2/src/app/capability/capabilityService.ts @@ -0,0 +1,222 @@ +import { homedir } from 'node:os'; + +import { KIMI_CODE_PROVIDER_NAME, resolveKimiRegion } from '@moonshot-ai/kimi-code-oauth'; + +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Disposable } from '#/_base/di/lifecycle'; +import { Emitter, type Event } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; +import { Error2 } from '#/errors'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IPluginService } from '#/app/plugin/plugin'; +import { IProviderService } from '#/llm-adapter/provider/provider'; +import { IHostProcessService } from '#/os/interface/hostProcess'; + +import { ICapabilityService } from './capability'; +import { CapabilityErrors } from './errors'; +import { createKimiCuEntry } from './entries/kimiCu'; +import { createKimiWebbridgeEntry } from './entries/kimiWebbridge'; +import type { + CapabilityEntry, + CapabilityId, + CapabilityDescriptor, + CapabilityInstallChange, + CapabilityInstallProgress, + CapabilityReadiness, + CapabilityStatus, +} from './types'; + +const IDLE_PROGRESS: CapabilityInstallProgress = { running: false }; + +export class CapabilityService extends Disposable implements ICapabilityService { + declare readonly _serviceBrand: undefined; + + private readonly onDidChangeInstallEmitter = this._register( + new Emitter(), + ); + readonly onDidChangeInstall: Event = + this.onDidChangeInstallEmitter.event; + + private readonly entries: ReadonlyMap; + private readonly installProgress = new Map(); + private readonly runningInstalls = new Set(); + + private setInstallProgress(id: CapabilityId, progress: CapabilityInstallProgress): void { + this.installProgress.set(id, progress); + this.onDidChangeInstallEmitter.fire({ id, install: progress }); + } + + constructor( + @IBootstrapService bootstrap: IBootstrapService, + @IPluginService plugins: IPluginService, + @IHostProcessService hostProcess: IHostProcessService, + @ILogService private readonly log: ILogService, + @IProviderService providers: IProviderService, + entriesOverride?: readonly CapabilityEntry[], + ) { + super(); + if (entriesOverride !== undefined) { + this.entries = new Map(entriesOverride.map((entry) => [entry.id, entry])); + } else { + const ctx = { + platform: process.platform, + arch: process.arch, + kimiHomeDir: bootstrap.homeDir, + userHomeDir: homedir(), + plugins, + hostProcess, + resolveRegion: () => { + const oauth = providers.get(KIMI_CODE_PROVIDER_NAME)?.oauth; + return resolveKimiRegion({ + configuredOAuthHost: oauth?.oauthHost, + configuredOAuthKey: oauth?.key, + readMarker: + (bootstrap.getEnv('KIMI_CODE_REGION_MARKER') ?? + process.env['KIMI_CODE_REGION_MARKER']) !== 'off', + homeDir: bootstrap.homeDir, + }); + }, + }; + this.entries = new Map([ + ['kimi-cu', createKimiCuEntry(ctx)], + ['kimi-webbridge', createKimiWebbridgeEntry(ctx)], + ]); + } + } + + describeCapabilities(): readonly CapabilityDescriptor[] { + return [...this.entries.values()].map((entry) => ({ + id: entry.id, + pluginId: entry.pluginId, + displayName: entry.displayName, + description: entry.description, + supported: entry.supported, + })); + } + + listCapabilities(): Promise { + return Promise.all([...this.entries.values()].map((entry) => this.statusOfSafe(entry))); + } + + async getCapability(id: string): Promise { + return this.statusOf(this.requireEntry(id)); + } + + async installCapability(id: string): Promise { + const entry = this.requireEntry(id); + if (!entry.supported) { + throw new Error2( + CapabilityErrors.codes.CAPABILITY_UNSUPPORTED, + `Capability "${entry.id}" is not supported on ${process.platform}/${process.arch}`, + { details: { id: entry.id } }, + ); + } + if (this.runningInstalls.has(entry.id)) { + throw new Error2( + CapabilityErrors.codes.CAPABILITY_INSTALL_IN_PROGRESS, + `Capability "${entry.id}" is already being installed`, + { details: { id: entry.id } }); + } + + this.runningInstalls.add(entry.id); + this.setInstallProgress(entry.id, { running: true }); + void (async () => { + try { + const note = await entry.install((step, percent) => { + this.setInstallProgress( + entry.id, + percent === undefined ? { running: true, step } : { running: true, step, percent }, + ); + }); + this.setInstallProgress(entry.id, { running: false, note }); + } catch (error) { + const step = this.installProgress.get(entry.id)?.step; + this.log.warn('capability install failed', { + capabilityId: entry.id, + step, + error, + }); + this.setInstallProgress(entry.id, { + running: false, + error: error instanceof Error ? error.message : String(error), + }); + } finally { + this.runningInstalls.delete(entry.id); + } + })(); + + return this.statusOf(entry); + } + + private requireEntry(id: string): CapabilityEntry { + const entry = this.entries.get(id as CapabilityId); + if (entry === undefined) { + throw new Error2( + CapabilityErrors.codes.CAPABILITY_NOT_FOUND, + `Capability "${id}" is not registered`, + { details: { id } }, + ); + } + return entry; + } + + private async statusOf(entry: CapabilityEntry): Promise { + const install = this.installProgress.get(entry.id) ?? IDLE_PROGRESS; + const base = { + id: entry.id, + pluginId: entry.pluginId, + displayName: entry.displayName, + description: entry.description, + install, + }; + if (!entry.supported) { + return { ...base, supported: false, state: 'unsupported', steps: [] }; + } + const detected = await entry.detect(); + const required = detected.steps.filter((step) => step.optional !== true); + const requiredOk = required.length > 0 && required.every((step) => step.state === 'ok'); + const anyOk = detected.steps.some((step) => step.state === 'ok'); + const state: CapabilityReadiness = requiredOk ? 'ready' : anyOk ? 'partial' : 'not_installed'; + return { + ...base, + supported: true, + state, + steps: detected.steps, + version: detected.version, + }; + } + + private async statusOfSafe(entry: CapabilityEntry): Promise { + try { + return await this.statusOf(entry); + } catch (error) { + const install = this.installProgress.get(entry.id) ?? IDLE_PROGRESS; + const detail = error instanceof Error ? error.message : String(error); + const base = { + id: entry.id, + pluginId: entry.pluginId, + displayName: entry.displayName, + description: entry.description, + install, + }; + if (!entry.supported) { + return { ...base, supported: false, state: 'unsupported', steps: [] }; + } + return { + ...base, + supported: true, + state: 'partial', + steps: [{ id: 'detect', state: 'failed' as const, detail }], + }; + } + } +} + +registerScopedService( + LifecycleScope.App, + ICapabilityService, + CapabilityService, + ScopeActivation.OnScopeCreated, + 'capability', +); diff --git a/packages/agent-core-v2/src/app/capability/errors.ts b/packages/agent-core-v2/src/app/capability/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..d88d5ce4ddc8a68b72e7c5c3811154998b09d752 --- /dev/null +++ b/packages/agent-core-v2/src/app/capability/errors.ts @@ -0,0 +1,11 @@ +import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; + +export const CapabilityErrors = { + codes: { + CAPABILITY_NOT_FOUND: 'capability.not_found', + CAPABILITY_UNSUPPORTED: 'capability.unsupported', + CAPABILITY_INSTALL_IN_PROGRESS: 'capability.install_in_progress', + }, +} as const satisfies ErrorDomain; + +registerErrorDomain(CapabilityErrors); diff --git a/packages/agent-core-v2/src/app/capability/host.ts b/packages/agent-core-v2/src/app/capability/host.ts new file mode 100644 index 0000000000000000000000000000000000000000..2d1f4c8bb4d18c1030aa90b577e6ae54ac333d24 --- /dev/null +++ b/packages/agent-core-v2/src/app/capability/host.ts @@ -0,0 +1,141 @@ +import { createWriteStream } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import { Readable, Transform } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +import type { IHostProcessService } from '#/os/interface/hostProcess'; + +export interface CommandResult { + readonly code: number; + readonly stdout: string; + readonly stderr: string; +} + +async function collect(stream: Readable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : (chunk as Buffer)); + } + return Buffer.concat(chunks).toString('utf-8'); +} + +export async function runCommand( + hostProcess: IHostProcessService, + command: string, + args: readonly string[], + options: { timeout?: number } = {}, +): Promise { + const spawned = await hostProcess.spawn(command, args, { windowsHide: true }).then( + (proc) => ({ ok: true as const, proc }), + (error: unknown) => ({ ok: false as const, error }), + ); + if (!spawned.ok) { + return { code: -1, stdout: '', stderr: spawned.error instanceof Error ? spawned.error.message : String(spawned.error) }; + } + const { proc } = spawned; + try { + const work = Promise.all([ + collect(proc.stdout), + collect(proc.stderr), + proc.wait().catch(() => -1), + ] as const); + let timer: NodeJS.Timeout | undefined; + const timed = options.timeout === undefined + ? work + : Promise.race([ + work, + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + void proc.kill().catch(() => {}); + reject(new Error(`command timed out after ${options.timeout}ms: ${command}`)); + }, options.timeout); + timer.unref?.(); + }), + ]); + try { + const [stdout, stderr, code] = await timed; + return { code, stdout, stderr }; + } finally { + if (timer !== undefined) clearTimeout(timer); + } + } finally { + void proc.dispose(); + } +} + +export type FetchLike = ( + url: string, + init?: { signal?: AbortSignal }, +) => Promise<{ + ok: boolean; + status: number; + headers: { get(name: string): string | null }; + body: object | null; +}>; + +export async function downloadToFile( + url: string, + destPath: string, + onPercent?: (percent: number) => void, + fetchImpl: FetchLike = fetch as unknown as FetchLike, + options: { idleTimeoutMs?: number } = {}, +): Promise { + const idleTimeoutMs = options.idleTimeoutMs ?? DOWNLOAD_IDLE_TIMEOUT_MS; + const headerController = new AbortController(); + const headerTimer = setTimeout(() => { + headerController.abort(); + }, idleTimeoutMs); + headerTimer.unref?.(); + let resp; + try { + resp = await fetchImpl(url, { signal: headerController.signal }); + } catch (error) { + if (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError')) { + throw new Error(`Failed to download ${url}: no response within ${idleTimeoutMs}ms`, { + cause: error, + }); + } + throw error; + } finally { + clearTimeout(headerTimer); + } + if (!resp.ok || resp.body === null) { + throw new Error(`Failed to download ${url}: HTTP ${resp.status}`); + } + const total = Number(resp.headers.get('content-length') ?? 0); + await mkdir(path.dirname(destPath), { recursive: true }); + let received = 0; + let idleTimer: NodeJS.Timeout | undefined; + const meter = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + armIdleWatchdog(); + received += chunk.length; + if (total > 0 && onPercent !== undefined) { + onPercent(Math.min(99, Math.floor((received / total) * 100))); + } + callback(null, chunk); + }, + }); + function armIdleWatchdog(): void { + if (idleTimer !== undefined) clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + meter.destroy(new Error(`Download stalled for ${idleTimeoutMs}ms: ${url}`)); + }, idleTimeoutMs); + idleTimer.unref?.(); + } + armIdleWatchdog(); + try { + await pipeline( + Readable.fromWeb(resp.body as import('node:stream/web').ReadableStream), + meter, + createWriteStream(destPath), + ); + } finally { + if (idleTimer !== undefined) clearTimeout(idleTimer); + } + onPercent?.(100); + return received; +} + +const DOWNLOAD_IDLE_TIMEOUT_MS = 30_000; diff --git a/packages/agent-core-v2/src/app/capability/types.ts b/packages/agent-core-v2/src/app/capability/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..e6072b051681b83b5ee622a659decdf191354068 --- /dev/null +++ b/packages/agent-core-v2/src/app/capability/types.ts @@ -0,0 +1,62 @@ +export type CapabilityId = 'kimi-cu' | 'kimi-webbridge'; + +export type CapabilityReadiness = 'not_installed' | 'partial' | 'ready' | 'unsupported'; + +export type CapabilityStepState = 'ok' | 'missing' | 'failed'; + +export interface CapabilityStep { + readonly id: string; + readonly state: CapabilityStepState; + readonly detail?: string; + readonly optional?: boolean; +} + +export interface CapabilityInstallProgress { + readonly running: boolean; + readonly step?: string; + readonly percent?: number; + readonly error?: string; + readonly note?: string; +} + +export interface CapabilityDetectResult { + readonly version?: string; + readonly steps: readonly CapabilityStep[]; +} + +export interface CapabilityStatus { + readonly id: CapabilityId; + readonly pluginId?: string; + readonly displayName: string; + readonly description: string; + readonly supported: boolean; + readonly state: CapabilityReadiness; + readonly version?: string; + readonly steps: readonly CapabilityStep[]; + readonly install: CapabilityInstallProgress; +} + +export type CapabilityInstallReporter = (step: string, percent?: number) => void; + +export interface CapabilityDescriptor { + readonly id: CapabilityId; + readonly pluginId?: string; + readonly displayName: string; + readonly description: string; + readonly supported: boolean; +} + +export interface CapabilityInstallChange { + readonly id: CapabilityId; + readonly install: CapabilityInstallProgress; +} + +export interface CapabilityEntry { + readonly id: CapabilityId; + readonly pluginId?: string; + readonly displayName: string; + readonly description: string; + readonly supported: boolean; + detect(): Promise; + install(report: CapabilityInstallReporter): Promise; +} diff --git a/packages/agent-core-v2/src/app/config/config.ts b/packages/agent-core-v2/src/app/config/config.ts new file mode 100644 index 0000000000000000000000000000000000000000..fc0db298859bc4eda8e89974de57514d61c59787 --- /dev/null +++ b/packages/agent-core-v2/src/app/config/config.ts @@ -0,0 +1,217 @@ +import type { Event } from '#/_base/event'; +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +import { isPlainObject } from './configPure'; + +export interface ConfigSchema { + parse(value: unknown): T; +} + +export type ConfigMerge = (base: T | undefined, patch: unknown) => T; + +export type EnvBinding = + | string + | { + readonly env: string; + readonly deprecatedEnv?: string; + readonly parse?: (raw: string) => unknown; + readonly default?: unknown; + }; + +export interface ConfigKeyDeprecation { + readonly key: string; + readonly replacement: string; + readonly message?: string; +} + +export type ConfigCollectDiagnostics = (rawSection: unknown) => readonly ConfigDiagnostic[]; + +export type EnvBindings = EnvBinding | { [K in keyof T]?: EnvBinding | EnvBindings }; + +export type AnyEnvBindings = EnvBinding | { readonly [key: string]: EnvBinding | AnyEnvBindings }; + +export function envBindings(_schema: ConfigSchema, bindings: EnvBindings): EnvBindings { + return bindings; +} + +export type ConfigStripEnv = ( + value: T, + raw?: unknown, + getEnv?: (name: string) => string | undefined, +) => T | undefined; + +function isEnvBinding(value: unknown): value is EnvBinding { + return typeof value === 'string' || (isPlainObject(value) && 'env' in value); +} + +export function stripEnvBoundFields(bindings: EnvBindings): ConfigStripEnv { + return (value, raw, getEnv) => { + if (getEnv === undefined || value === null || typeof value !== 'object') return value; + if (!isPlainObject(bindings) || isEnvBinding(bindings)) return value; + const base = isPlainObject(raw) ? raw : {}; + let out: Record | undefined; + for (const [field, binding] of Object.entries(bindings)) { + if (binding === undefined || !isEnvBinding(binding)) continue; + if (!resolvesFromEnv(binding, getEnv)) continue; + out ??= { ...(value as Record) }; + if (base[field] !== undefined) { + out[field] = base[field]; + } else { + delete out[field]; + } + } + if (out === undefined) return value; + if (Object.keys(out).length > 0) return out as T; + return (Object.keys(base).length > 0 ? base : undefined) as T | undefined; + }; +} + +function resolvesFromEnv(binding: EnvBinding, getEnv: (name: string) => string | undefined): boolean { + const parse = typeof binding === 'string' ? undefined : binding.parse; + const names = + typeof binding === 'string' + ? [binding] + : binding.deprecatedEnv === undefined + ? [binding.env] + : [binding.env, binding.deprecatedEnv]; + return names.some((name) => { + const raw = getEnv(name); + return raw !== undefined && (parse === undefined || parse(raw) !== undefined); + }); +} + +export type ConfigFromToml = (rawSnake: unknown) => unknown; + +export type ConfigToToml = (value: unknown, rawSnake: unknown) => unknown; + +export interface ConfigSection { + readonly domain: string; + readonly schema?: ConfigSchema; + readonly defaultValue?: T; + readonly merge: ConfigMerge; + readonly scope: ConfigScope; + readonly env?: AnyEnvBindings; + readonly stripEnv?: ConfigStripEnv; + readonly fromToml?: ConfigFromToml; + readonly toToml?: ConfigToToml; + readonly deprecations?: readonly ConfigKeyDeprecation[]; + readonly collectDiagnostics?: ConfigCollectDiagnostics; +} + +export interface RegisterSectionOptions { + readonly defaultValue?: T; + readonly merge?: ConfigMerge; + readonly scope?: ConfigScope; + readonly env?: EnvBindings; + readonly stripEnv?: ConfigStripEnv; + readonly fromToml?: ConfigFromToml; + readonly toToml?: ConfigToToml; + readonly deprecations?: readonly ConfigKeyDeprecation[]; + readonly collectDiagnostics?: ConfigCollectDiagnostics; +} + +export interface ConfigEffectiveOverlay { + apply( + effective: Record, + getEnv: (name: string) => string | undefined, + validate: (domain: string, value: unknown) => unknown, + ): readonly string[]; + strip?( + domain: string, + value: unknown, + rawSnake: Record, + ): unknown; +} + +export interface IConfigRegistry { + readonly _serviceBrand: undefined; + + readonly onDidRegisterSection: Event; + readonly onDidUnregisterSection: Event; + readonly onDidRegisterOverlay: Event; + registerSection(domain: string, schema: ConfigSchema, options?: RegisterSectionOptions): void; + unregisterSection(domain: string): void; + getSection(domain: string): ConfigSection | undefined; + listSections(): readonly ConfigSection[]; + registerEffectiveOverlay(overlay: ConfigEffectiveOverlay): void; + listEffectiveOverlays(): readonly ConfigEffectiveOverlay[]; + validate(domain: string, value: unknown): T; + merge(domain: string, base: T | undefined, patch: unknown): T; + defaultValue(domain: string): T | undefined; +} + +export interface ConfigSectionRegisteredEvent { + readonly domain: string; +} + +export interface ConfigOverlayRegisteredEvent { + readonly overlay: ConfigEffectiveOverlay; +} + +export const IConfigRegistry: ServiceIdentifier = + createDecorator('configRegistry'); + +export type ConfigChangeSource = 'load' | 'reload' | 'set'; + +export interface ConfigChangedEvent { + readonly domain: string; + readonly source: ConfigChangeSource; + readonly value: unknown; + readonly previousValue: unknown; +} + +export interface ConfigSectionChangedEvent { + readonly domain: string; + readonly source: ConfigChangeSource; + readonly value: unknown; + readonly previousValue: unknown; +} + +export interface ConfigDiagnostic { + readonly domain?: string; + readonly severity: 'warning' | 'error'; + readonly message: string; +} + +export type ResolvedConfig = Record; + +export enum ConfigScope { + Core = 'core', + Session = 'session', + Project = 'project', +} + +export enum ConfigTarget { + User = 'user', + Memory = 'memory', +} + +export interface ConfigInspectValue { + readonly value: T | undefined; + readonly defaultValue: T | undefined; + readonly userValue: T | undefined; + readonly memoryValue: T | undefined; +} + +export interface IConfigService { + readonly _serviceBrand: undefined; + + readonly ready: Promise; + readonly onDidChangeConfiguration: Event; + readonly onDidSectionChange: Event; + readonly onDidChangeDiagnostics: Event; + get(domain: string): T; + inspect(domain: string): ConfigInspectValue; + getAll(): ResolvedConfig; + set(domain: string, patch: unknown, target?: ConfigTarget): Promise; + replace(domain: string, value: unknown, target?: ConfigTarget): Promise; + replaceSections( + sections: Readonly>, + target?: ConfigTarget, + ): Promise; + reload(): Promise; + diagnostics(): readonly ConfigDiagnostic[]; +} + +export const IConfigService: ServiceIdentifier = + createDecorator('configService'); diff --git a/packages/agent-core-v2/src/app/config/configEvents.ts b/packages/agent-core-v2/src/app/config/configEvents.ts new file mode 100644 index 0000000000000000000000000000000000000000..9d420a1cd711a601691444b347ca1b63ef0e5acd --- /dev/null +++ b/packages/agent-core-v2/src/app/config/configEvents.ts @@ -0,0 +1,71 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { Event2 } from '#/app/event/event2'; + +export interface ConfigWarningItem { + readonly domain?: string; + readonly message: string; +} + +export interface ConfigWarningPayload { + readonly warnings: readonly ConfigWarningItem[]; +} + +export class ConfigWarning extends Event2<{ readonly payload: ConfigWarningPayload }> { + static override readonly type = 'event.config.warning'; +} +export interface ConfigWarning { + readonly payload: ConfigWarningPayload; +} + +export interface ConfigChangedPayload { + readonly changedFields: readonly string[]; + readonly config: unknown; +} + +export class ConfigChanged extends Event2<{ readonly payload: ConfigChangedPayload }> { + static override readonly type = 'event.config.changed'; +} +export interface ConfigChanged { + readonly payload: ConfigChangedPayload; +} + +export interface ProviderConfigResponse { + type: string; + base_url?: string; + default_model?: string; + has_api_key: boolean; +} + +export interface ConfigResponse { + providers: Record; + default_provider?: string; + default_model?: string; + models?: Record; + thinking?: unknown; + plan_mode?: boolean; + yolo?: boolean; + default_permission_mode?: string; + default_plan_mode?: boolean; + permission?: unknown; + hooks?: unknown[]; + services?: unknown; + merge_all_available_skills?: boolean; + extra_skill_dirs?: string[]; + loop_control?: unknown; + background?: unknown; + experimental?: Record; + telemetry?: boolean; + raw?: Record; + [key: string]: unknown; +} + +export interface ConfigChangedEvent { + readonly type: 'event.config.changed'; + readonly changedFields: string[]; + readonly config: ConfigResponse; +} + +export interface ConfigWarningEvent { + readonly type: 'event.config.warning'; + readonly warnings: readonly ConfigWarningItem[]; +} diff --git a/packages/agent-core-v2/src/app/config/configOverlayContributions.ts b/packages/agent-core-v2/src/app/config/configOverlayContributions.ts new file mode 100644 index 0000000000000000000000000000000000000000..75eec4d644b171add03405d029a235bf912038fa --- /dev/null +++ b/packages/agent-core-v2/src/app/config/configOverlayContributions.ts @@ -0,0 +1,15 @@ +import type { ConfigEffectiveOverlay } from './config'; + +const _overlays: ConfigEffectiveOverlay[] = []; + +export function registerConfigOverlay(overlay: ConfigEffectiveOverlay): void { + _overlays.push(overlay); +} + +export function getConfigOverlayContributions(): readonly ConfigEffectiveOverlay[] { + return _overlays; +} + +export function _clearConfigOverlayContributionsForTests(): void { + _overlays.length = 0; +} diff --git a/packages/agent-core-v2/src/app/config/configPure.ts b/packages/agent-core-v2/src/app/config/configPure.ts new file mode 100644 index 0000000000000000000000000000000000000000..9a927001a92d899bad555747a85f4fbcd9153b90 --- /dev/null +++ b/packages/agent-core-v2/src/app/config/configPure.ts @@ -0,0 +1,51 @@ +export function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i])) return false; + } + return true; + } + if (isPlainObject(a) && isPlainObject(b)) { + const aKeys = Object.keys(a); + if (aKeys.length !== Object.keys(b).length) return false; + for (const key of aKeys) { + if (!Object.prototype.hasOwnProperty.call(b, key) || !deepEqual(a[key], b[key])) return false; + } + return true; + } + return false; +} + +export function deepMerge(base: T | undefined, patch: unknown): T { + if (!isPlainObject(base) || !isPlainObject(patch)) { + return (patch ?? base) as T; + } + const out: Record = { ...base }; + for (const key of Object.keys(patch)) { + const pv = patch[key]; + const bv = out[key]; + out[key] = isPlainObject(bv) && isPlainObject(pv) ? deepMerge(bv, pv) : pv; + } + return out as T; +} + +export function omitUndefined>(value: T): Partial { + const out: Partial = {}; + for (const key of Object.keys(value)) { + const v = value[key]; + if (v !== undefined) { + out[key as keyof T] = v as T[keyof T]; + } + } + return out; +} + +export function describeUnknownError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/agent-core-v2/src/app/config/configSectionContributions.ts b/packages/agent-core-v2/src/app/config/configSectionContributions.ts new file mode 100644 index 0000000000000000000000000000000000000000..c409ac19bb8a5dede1019aee9a49dc91ba2211bf --- /dev/null +++ b/packages/agent-core-v2/src/app/config/configSectionContributions.ts @@ -0,0 +1,32 @@ +import { collection } from '#/_base/di/collection'; +import type { ConfigSchema, RegisterSectionOptions } from './config'; + +export interface ConfigSectionContribution { + readonly domain: string; + readonly schema: ConfigSchema; + readonly options: RegisterSectionOptions; +} + +export const ConfigSectionContribution = collection('config-section'); + +const _contributions: ConfigSectionContribution[] = []; + +export function registerConfigSection( + domain: string, + schema: ConfigSchema, + options: RegisterSectionOptions = {}, +): void { + _contributions.push({ + domain, + schema: schema as ConfigSchema, + options: options as RegisterSectionOptions, + }); +} + +export function getConfigSectionContributions(): readonly ConfigSectionContribution[] { + return _contributions; +} + +export function _clearConfigSectionContributionsForTests(): void { + _contributions.length = 0; +} diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts new file mode 100644 index 0000000000000000000000000000000000000000..fadb6a96ff529a300da0f4109d2dfce99a264dc8 --- /dev/null +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -0,0 +1,885 @@ +import { readFileSync } from 'node:fs'; + +import { join, normalize } from 'pathe'; +import { parse as parseToml } from 'smol-toml'; + +import { type CollectionView } from '#/_base/di/collection'; +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Emitter, type Event } from '#/_base/event'; +import { TimeoutTimer } from '#/_base/utils/timer'; +import { BugIndicatingError, Error2, ErrorCodes, onUnexpectedError } from '#/errors'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { ILogService } from '#/_base/log/log'; +import { IAtomicTomlDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { watch } from '#human/utils/watch'; + +import { + type AnyEnvBindings, + type ConfigChangedEvent, + type ConfigDiagnostic, + type ConfigSectionChangedEvent, + type ConfigEffectiveOverlay, + type ConfigInspectValue, + type ConfigMerge, + type ConfigOverlayRegisteredEvent, + type ConfigSchema, + type ConfigSection, + type ConfigSectionRegisteredEvent, + type ConfigChangeSource, + type EnvBinding, + type RegisterSectionOptions, + type ResolvedConfig, + ConfigScope, + ConfigTarget, + IConfigRegistry, + IConfigService, +} from './config'; +import { deepEqual, deepMerge, describeUnknownError, isPlainObject } from './configPure'; +import { + ConfigSectionContribution, + getConfigSectionContributions, +} from './configSectionContributions'; +import { getConfigOverlayContributions } from './configOverlayContributions'; +import { collectKeyDeprecations } from './deprecations'; +import { + applySectionToToml, + camelToSnake, + cloneRecord, + describeTomlSyntaxError, + TomlError, + transformTomlData, +} from './toml'; +import { planConfigWriteback } from './tomlWriteback'; + +const CONFIG_SCOPE = ''; +const WATCH_DEBOUNCE_MS = 150; + +type GetEnv = (name: string) => string | undefined; + +type OnDeprecatedEnv = (oldName: string, newName: string) => void; + +function isEnvBinding(value: unknown): value is EnvBinding { + return typeof value === 'string' || (isPlainObject(value) && 'env' in value); +} + +function parseBoundRaw(binding: EnvBinding, raw: string): unknown { + return typeof binding === 'string' ? raw : binding.parse ? binding.parse(raw) : raw; +} + +function resolveBinding( + binding: EnvBinding, + getEnv: GetEnv, + existing: unknown, + onDeprecatedEnv?: OnDeprecatedEnv, +): unknown { + if (typeof binding !== 'string') { + const raw = getEnv(binding.env); + if (raw !== undefined) { + const parsed = parseBoundRaw(binding, raw); + if (parsed !== undefined) return parsed; + } + if (binding.deprecatedEnv !== undefined) { + const deprecatedRaw = getEnv(binding.deprecatedEnv); + if (deprecatedRaw !== undefined) { + const parsed = parseBoundRaw(binding, deprecatedRaw); + if (parsed !== undefined) { + onDeprecatedEnv?.(binding.deprecatedEnv, binding.env); + return parsed; + } + } + } + } else { + const raw = getEnv(binding); + if (raw !== undefined) return raw; + } + if (typeof binding === 'object' && binding.default !== undefined && existing === undefined) { + return binding.default; + } + return existing; +} + +function applyEnvBindings( + target: Record, + bindings: AnyEnvBindings, + getEnv: GetEnv, + onDeprecatedEnv?: OnDeprecatedEnv, +): void { + for (const [key, binding] of Object.entries(bindings)) { + if (isEnvBinding(binding)) { + const resolved = resolveBinding(binding, getEnv, target[key], onDeprecatedEnv); + if (resolved !== undefined) target[key] = resolved; + } else if (binding !== undefined) { + const child: Record = isPlainObject(target[key]) + ? { ...target[key] } + : {}; + target[key] = child; + applyEnvBindings(child, binding as AnyEnvBindings, getEnv, onDeprecatedEnv); + if (Object.keys(child).length === 0) { + delete target[key]; + } + } + } +} + +export function applySectionEnv( + base: unknown, + env: AnyEnvBindings, + getEnv: GetEnv, + onDeprecatedEnv?: OnDeprecatedEnv, +): unknown { + if (isEnvBinding(env)) { + return resolveBinding(env, getEnv, base, onDeprecatedEnv); + } + const target: Record = isPlainObject(base) ? { ...base } : {}; + applyEnvBindings(target, env, getEnv, onDeprecatedEnv); + return target; +} + +function isSameSection( + existing: ConfigSection, + schema: ConfigSchema, + options: RegisterSectionOptions, +): boolean { + return ( + existing.schema === schema && + existing.merge === (options.merge ?? deepMerge) && + existing.scope === (options.scope ?? ConfigScope.Core) && + existing.env === (options.env as ConfigSection['env']) && + existing.stripEnv === (options.stripEnv as ConfigSection['stripEnv']) && + existing.fromToml === options.fromToml && + existing.toToml === options.toToml && + deepEqual(existing.defaultValue, options.defaultValue) && + deepEqual(existing.deprecations, options.deprecations) && + existing.collectDiagnostics === options.collectDiagnostics + ); +} + +export class ConfigRegistry extends Disposable implements IConfigRegistry { + declare readonly _serviceBrand: undefined; + private readonly sections = new Map(); + private readonly overlays: ConfigEffectiveOverlay[] = []; + private readonly _onDidRegisterSection = this._register( + new Emitter(), + ); + readonly onDidRegisterSection: Event = + this._onDidRegisterSection.event; + private readonly _onDidUnregisterSection = this._register( + new Emitter(), + ); + readonly onDidUnregisterSection: Event = + this._onDidUnregisterSection.event; + private readonly _onDidRegisterOverlay = this._register( + new Emitter(), + ); + readonly onDidRegisterOverlay: Event = + this._onDidRegisterOverlay.event; + private readonly foldDomains = new Set(); + + constructor( + @ConfigSectionContribution view?: CollectionView, + ) { + super(); + for (const c of getConfigSectionContributions()) { + this.registerSection(c.domain, c.schema, c.options); + } + for (const overlay of getConfigOverlayContributions()) { + this.registerEffectiveOverlay(overlay); + } + if (view === undefined) return; + for (const item of view.items) { + this.addContribution(item); + } + this._register( + view.onDidChange((change) => { + for (const contribution of change.removed) { + this.removeContribution(contribution); + } + for (const contribution of change.added) { + this.addContribution(contribution); + } + }), + ); + } + + private addContribution(contribution: ConfigSectionContribution): void { + const before = this.sections.get(contribution.domain); + try { + this.registerSection(contribution.domain, contribution.schema, contribution.options); + } catch (error) { + onUnexpectedError(error); + return; + } + if (before === undefined && this.sections.get(contribution.domain) !== undefined) { + this.foldDomains.add(contribution.domain); + } + } + + private removeContribution(contribution: ConfigSectionContribution): void { + if (!this.foldDomains.delete(contribution.domain)) return; + this.unregisterSection(contribution.domain); + } + + registerSection( + domain: string, + schema: ConfigSchema, + options: RegisterSectionOptions = {}, + ): void { + const existing = this.sections.get(domain); + if (existing !== undefined) { + if ( + isSameSection( + existing, + schema as ConfigSchema, + options as RegisterSectionOptions, + ) + ) { + return; + } + throw new BugIndicatingError(`ConfigRegistry: section '${domain}' is already registered`); + } + this.sections.set(domain, { + domain, + schema: schema as ConfigSchema, + defaultValue: options.defaultValue, + merge: (options.merge ?? deepMerge) as ConfigMerge, + scope: options.scope ?? ConfigScope.Core, + env: options.env as ConfigSection['env'], + stripEnv: options.stripEnv as ConfigSection['stripEnv'], + fromToml: options.fromToml, + toToml: options.toToml, + deprecations: options.deprecations, + collectDiagnostics: options.collectDiagnostics, + }); + this._onDidRegisterSection.fire({ domain }); + } + + unregisterSection(domain: string): void { + if (!this.sections.delete(domain)) return; + this._onDidUnregisterSection.fire({ domain }); + } + + getSection(domain: string): ConfigSection | undefined { + return this.sections.get(domain); + } + + listSections(): readonly ConfigSection[] { + return [...this.sections.values()]; + } + + registerEffectiveOverlay(overlay: ConfigEffectiveOverlay): void { + this.overlays.push(overlay); + this._onDidRegisterOverlay.fire({ overlay }); + } + + listEffectiveOverlays(): readonly ConfigEffectiveOverlay[] { + return [...this.overlays]; + } + + validate(domain: string, value: unknown): T { + const schema = this.sections.get(domain)?.schema; + return (schema === undefined ? value : schema.parse(value)) as T; + } + + merge(domain: string, base: T | undefined, patch: unknown): T { + const merge = this.sections.get(domain)?.merge ?? deepMerge; + return merge(base, patch) as T; + } + + defaultValue(domain: string): T | undefined { + return this.sections.get(domain)?.defaultValue as T | undefined; + } +} + +export class ConfigService extends Disposable implements IConfigService { + declare readonly _serviceBrand: undefined; + private readonly _onDidChangeConfiguration = this._register(new Emitter()); + readonly onDidChangeConfiguration: Event = this._onDidChangeConfiguration.event; + private readonly _onDidSectionChange = this._register(new Emitter()); + readonly onDidSectionChange: Event = this._onDidSectionChange.event; + private readonly _onDidChangeDiagnostics = this._register( + new Emitter(), + ); + readonly onDidChangeDiagnostics: Event = + this._onDidChangeDiagnostics.event; + readonly ready: Promise; + + private stateChain: Promise = Promise.resolve(); + private readonly watchDebounce = this._register(new TimeoutTimer()); + + private rawSnake: ResolvedConfig = {}; + private raw: ResolvedConfig = {}; + private validated: ResolvedConfig = {}; + private effective: ResolvedConfig = {}; + private memory: ResolvedConfig = {}; + private delivered: ResolvedConfig = {}; + private readonly diagnosticsList: ConfigDiagnostic[] = []; + private lastDiagnosticsSnapshot = '[]'; + private readonly configKey: string; + private tainted = false; + + constructor( + @IConfigRegistry private readonly registry: IConfigRegistry, + @IBootstrapService private readonly bootstrap: IBootstrapService, + @ILogService private readonly log: ILogService, + @IAtomicTomlDocumentStore private readonly documentStore: IAtomicTomlDocumentStore, + ) { + super(); + this.configKey = this.bootstrap.configKey; + this._register(this.registry.onDidRegisterSection((e) => this.revalidateDomain(e.domain))); + this._register(this.registry.onDidUnregisterSection((e) => this.devalidateDomain(e.domain))); + this._register(this.registry.onDidRegisterOverlay(() => this.reapplyOverlays())); + const { configKey } = this; + const { homeDir } = this.bootstrap; + this.seedInitialLoad(); + this.ready = this.load('load'); + const configFile = join(homeDir, configKey); + const handle = watch(homeDir, { depth: 0 }); + this._register(handle); + this._register( + handle.onDidChange((change) => { + if (normalize(change.path) !== normalize(configFile)) return; + this.watchDebounce.cancelAndSet(() => { + void this.reload(); + }, WATCH_DEBOUNCE_MS); + }), + ); + } + + get(domain: string): T { + if (Object.prototype.hasOwnProperty.call(this.memory, domain)) { + return this.memory[domain] as T; + } + return this.freshEffective()[domain] as T; + } + + inspect(domain: string): ConfigInspectValue { + const memoryValue = this.memory[domain] as T | undefined; + return { + value: this.get(domain), + defaultValue: this.registry.defaultValue(domain), + userValue: this.raw[domain] as T | undefined, + memoryValue, + }; + } + + getAll(): ResolvedConfig { + return { ...this.freshEffective(), ...this.memory }; + } + + private freshEffective(): ResolvedConfig { + const effective: ResolvedConfig = { ...this.validated }; + this.applySectionEnvBindings(effective, false); + this.applyEnvOverlay(effective, false); + return effective; + } + + diagnostics(): readonly ConfigDiagnostic[] { + return [...this.diagnosticsList]; + } + + private pushDiagnostic(diagnostic: ConfigDiagnostic): void { + const duplicate = this.diagnosticsList.some( + (existing) => + existing.domain === diagnostic.domain && + existing.severity === diagnostic.severity && + existing.message === diagnostic.message, + ); + if (!duplicate) this.diagnosticsList.push(diagnostic); + } + + private emitDiagnosticsIfChanged(): void { + const snapshot = JSON.stringify(this.diagnosticsList); + if (snapshot === this.lastDiagnosticsSnapshot) return; + this.lastDiagnosticsSnapshot = snapshot; + this._onDidChangeDiagnostics.fire(this.diagnostics()); + } + + async set( + domain: string, + patch: unknown, + target: ConfigTarget = ConfigTarget.User, + ): Promise { + await this.ready; + if (target === ConfigTarget.Memory) { + const next = this.registry.merge(domain, this.memory[domain], patch); + const validated = this.registry.validate(domain, next); + if (validated === undefined) { + delete this.memory[domain]; + } else { + this.memory[domain] = validated; + } + this.commit('set', [domain]); + return; + } + await this.enqueueStateTransition(async () => { + this.assertPersistable(); + await this.persist(domain, (stagedRaw, stagedRawSnake) => { + const next = this.registry.merge(domain, stagedRaw[domain], patch); + const validated = this.registry.validate(domain, next); + const stripped = this.stripEnv(domain, validated, stagedRaw, stagedRawSnake); + if (stripped === undefined) { + delete stagedRaw[domain]; + } else { + this.registry.validate(domain, stripped); + stagedRaw[domain] = stripped; + } + }); + this.rebuildEffective('set', [domain]); + }); + } + + async replace( + domain: string, + value: unknown, + target: ConfigTarget = ConfigTarget.User, + ): Promise { + await this.ready; + const effectiveValue = value === null ? undefined : value; + if (target === ConfigTarget.Memory) { + if (effectiveValue === undefined) { + delete this.memory[domain]; + } else { + this.memory[domain] = this.registry.validate(domain, effectiveValue); + } + this.commit('set', [domain]); + return; + } + await this.enqueueStateTransition(async () => { + this.assertPersistable(); + await this.persist(domain, (stagedRaw, stagedRawSnake) => { + const stripped = this.stripEnv(domain, effectiveValue, stagedRaw, stagedRawSnake); + if (stripped === undefined) { + delete stagedRaw[domain]; + } else { + stagedRaw[domain] = this.registry.validate(domain, stripped); + } + }); + this.rebuildEffective('set', [domain]); + }); + } + + async replaceSections( + sections: Readonly>, + target: ConfigTarget = ConfigTarget.User, + ): Promise { + await this.ready; + const domains = Object.keys(sections); + if (domains.length === 0) return; + if (target === ConfigTarget.Memory) { + const staged: ResolvedConfig = { ...this.memory }; + for (const domain of domains) { + const value = sections[domain]; + if (value === undefined || value === null) { + delete staged[domain]; + } else { + staged[domain] = this.registry.validate(domain, value); + } + } + this.memory = staged; + this.commit('set', domains); + return; + } + await this.enqueueStateTransition(async () => { + this.assertPersistable(); + await this.persistDomains(domains, (stagedRaw, stagedRawSnake) => { + for (const domain of domains) { + const value = sections[domain] === null ? undefined : sections[domain]; + const stripped = this.stripEnv(domain, value, stagedRaw, stagedRawSnake); + if (stripped === undefined) { + delete stagedRaw[domain]; + } else { + stagedRaw[domain] = this.registry.validate(domain, stripped); + } + } + }); + this.rebuildEffective('set', domains); + }); + } + + private stripEnv( + domain: string, + value: unknown, + raw: ResolvedConfig, + rawSnake: ResolvedConfig, + ): unknown { + let result = value; + const section = this.registry.getSection(domain); + if (section?.stripEnv !== undefined) { + const getEnv = (name: string): string | undefined => this.bootstrap.getEnv(name); + result = section.stripEnv(result, raw[domain], getEnv); + } + if (result === undefined) return result; + for (const overlay of this.registry.listEffectiveOverlays()) { + if (overlay.strip === undefined) continue; + result = overlay.strip(domain, result, rawSnake); + if (result === undefined) return result; + } + return result; + } + + async reload(): Promise { + await this.ready; + await this.enqueueStateTransition(() => this.load('reload')); + } + + private enqueueStateTransition(fn: () => Promise): Promise { + const run = this.stateChain.then(() => fn()); + this.stateChain = run.then( + () => undefined, + () => undefined, + ); + return run; + } + + private seedInitialLoad(): void { + let fileData: ResolvedConfig; + try { + const text = readFileSync(this.bootstrap.configPath, 'utf8'); + const data: unknown = text.trim().length === 0 ? {} : parseToml(text); + if (!isPlainObject(data)) return; + fileData = data; + } catch { + return; + } + this.rawSnake = cloneRecord(fileData); + this.raw = transformTomlData(fileData, this.registry); + this.validated = this.buildValidated(this.raw); + const next = { ...this.validated }; + this.applySectionEnvBindings(next, true); + this.applyEnvOverlay(next); + this.effective = next; + } + + private async load(source: ConfigChangeSource): Promise { + this.diagnosticsList.length = 0; + let fileData: ResolvedConfig = {}; + let failed = false; + try { + const data = await this.documentStore.get(CONFIG_SCOPE, this.configKey); + fileData = data !== undefined && isPlainObject(data) ? data : {}; + } catch (error) { + failed = true; + const message = + error instanceof TomlError + ? `Failed to parse ${this.bootstrap.configPath}: ${describeTomlSyntaxError(error)}` + : describeUnknownError(error); + this.pushDiagnostic({ severity: 'error', message }); + this.log.warn('config load failed', { error: describeUnknownError(error) }); + if (source !== 'load') { + this.tainted = true; + this.emitDiagnosticsIfChanged(); + return; + } + } + this.tainted = failed; + const nextRawSnake = cloneRecord(fileData); + for (const diagnostic of collectKeyDeprecations(nextRawSnake, this.registry.listSections())) { + this.pushDiagnostic(diagnostic); + } + for (const section of this.registry.listSections()) { + if (section.collectDiagnostics === undefined) continue; + const rawSection = nextRawSnake[camelToSnake(section.domain)]; + for (const diagnostic of section.collectDiagnostics(rawSection)) { + this.pushDiagnostic(diagnostic); + } + } + if (source !== 'load' && JSON.stringify(nextRawSnake) === JSON.stringify(this.rawSnake)) { + const scratch = { ...this.validated }; + this.applySectionEnvBindings(scratch, true); + this.applyEnvOverlay(scratch); + this.emitDiagnosticsIfChanged(); + return; + } + this.rawSnake = nextRawSnake; + this.raw = transformTomlData(fileData, this.registry); + this.rebuildEffective(source); + } + + private rebuildEffective( + source: ConfigChangeSource = 'reload', + domains?: readonly string[], + ): void { + const previous = this.effective; + this.validated = this.buildValidated(this.raw); + const next = { ...this.validated }; + this.applySectionEnvBindings(next, true); + this.applyEnvOverlay(next); + this.effective = next; + + const candidates = new Set( + domains ?? [...Object.keys(previous), ...Object.keys(next)], + ); + for (const domain of new Set([...Object.keys(previous), ...Object.keys(next)])) { + if (!deepEqual(previous[domain], next[domain])) candidates.add(domain); + } + this.commit(source, [...candidates]); + this.emitDiagnosticsIfChanged(); + } + + private deliveredValue(domain: string): unknown { + return Object.prototype.hasOwnProperty.call(this.memory, domain) + ? this.memory[domain] + : this.effective[domain]; + } + + private commit(source: ConfigChangeSource, domains: readonly string[]): void { + for (const domain of domains) { + const previousValue = this.delivered[domain]; + const value = this.deliveredValue(domain); + this._onDidChangeConfiguration.fire({ domain, source, value, previousValue }); + if (!deepEqual(value, previousValue)) { + this._onDidSectionChange.fire({ domain, source, value, previousValue }); + } + this.delivered[domain] = value; + } + } + + private buildValidated(raw: ResolvedConfig): ResolvedConfig { + const validated: ResolvedConfig = {}; + for (const [domain, value] of Object.entries(raw)) { + try { + validated[domain] = this.registry.validate(domain, value); + } catch (error) { + this.pushDiagnostic({ + domain, + severity: 'warning', + message: `Ignored invalid config section '${domain}': ${describeUnknownError(error)}`, + }); + } + } + for (const section of this.registry.listSections()) { + if (validated[section.domain] === undefined && section.defaultValue !== undefined) { + validated[section.domain] = section.defaultValue; + } + } + return validated; + } + + private applySectionEnvBindings(effective: ResolvedConfig, reportErrors: boolean): void { + const getEnv = (name: string): string | undefined => this.bootstrap.getEnv(name); + for (const section of this.registry.listSections()) { + if (section.env === undefined) continue; + try { + const base = effective[section.domain]; + const onDeprecatedEnv: OnDeprecatedEnv | undefined = reportErrors + ? (oldName, newName) => { + this.pushDiagnostic({ + domain: section.domain, + severity: 'warning', + message: `Environment variable ${oldName} is deprecated; use ${newName} instead.`, + }); + } + : undefined; + const next = applySectionEnv(base, section.env, getEnv, onDeprecatedEnv); + effective[section.domain] = this.registry.validate(section.domain, next); + } catch (error) { + if (reportErrors) { + this.pushDiagnostic({ + domain: section.domain, + severity: 'warning', + message: `Ignoring env overlay for '${section.domain}': ${describeUnknownError(error)}`, + }); + } + } + } + } + + private applyEnvOverlay(effective: ResolvedConfig, reportErrors = true): void { + const getEnv = (name: string): string | undefined => this.bootstrap.getEnv(name); + const validate = (domain: string, value: unknown): unknown => + this.registry.validate(domain, value); + for (const overlay of this.registry.listEffectiveOverlays()) { + try { + overlay.apply(effective, getEnv, validate); + } catch (error) { + if (reportErrors) { + this.pushDiagnostic({ + severity: 'warning', + message: `Ignoring config environment overlay: ${describeUnknownError(error)}`, + }); + } + } + } + } + + private reapplyOverlays(): void { + const before = this.effective; + this.validated = this.buildValidated(this.raw); + const next = { ...this.validated }; + this.applySectionEnvBindings(next, true); + this.applyEnvOverlay(next); + this.effective = next; + this.commit('reload', [...new Set([...Object.keys(before), ...Object.keys(next)])]); + this.emitDiagnosticsIfChanged(); + } + + private revalidateDomain(domain: string): void { + const section = this.registry.getSection(domain); + if (section === undefined) return; + + if (section.fromToml !== undefined) { + const rawSnakeValue = this.rawSnake[camelToSnake(domain)]; + if (rawSnakeValue !== undefined) { + this.raw[domain] = section.fromToml(rawSnakeValue); + } + } + + if (this.raw[domain] !== undefined) { + try { + const validatedValue = this.registry.validate(domain, this.raw[domain]); + this.validated[domain] = validatedValue; + this.effective[domain] = validatedValue; + } catch { + return; + } + } else if (section.defaultValue !== undefined && this.effective[domain] === undefined) { + this.validated[domain] = section.defaultValue; + this.effective[domain] = section.defaultValue; + } else { + return; + } + + this.applyEnvOverlay(this.effective); + if (section.env !== undefined) { + const getEnv = (name: string): string | undefined => this.bootstrap.getEnv(name); + try { + const onDeprecatedEnv: OnDeprecatedEnv = (oldName, newName) => { + this.pushDiagnostic({ + domain, + severity: 'warning', + message: `Environment variable ${oldName} is deprecated; use ${newName} instead.`, + }); + }; + const next = applySectionEnv(this.effective[domain], section.env, getEnv, onDeprecatedEnv); + this.effective[domain] = this.registry.validate(domain, next); + } catch (error) { + this.pushDiagnostic({ + domain, + severity: 'warning', + message: `Ignoring env overlay for '${domain}': ${describeUnknownError(error)}`, + }); + } + } + this.commit('reload', [domain]); + this.emitDiagnosticsIfChanged(); + } + + private devalidateDomain(domain: string): void { + if (this.registry.getSection(domain) !== undefined) return; + + const snakeKey = camelToSnake(domain); + const rawSnakeValue = this.rawSnake[snakeKey]; + if (rawSnakeValue === undefined) { + delete this.raw[domain]; + delete this.validated[domain]; + delete this.effective[domain]; + } else { + const raw = transformTomlData({ [snakeKey]: rawSnakeValue }, this.registry)[domain]; + this.raw[domain] = raw; + this.validated[domain] = raw; + this.effective[domain] = raw; + } + + this.applyEnvOverlay(this.effective); + this.commit('reload', [domain]); + } + + private assertPersistable(): void { + if (!this.tainted) return; + throw new Error2( + ErrorCodes.CONFIG_PERSIST_BLOCKED, + `Refusing to persist config: ${this.bootstrap.configPath} could not be read; fix the file and reload before writing.`, + ); + } + + private async persist( + domain: string, + rebase: (stagedRaw: ResolvedConfig, stagedRawSnake: ResolvedConfig) => void, + ): Promise { + await this.persistDomains([domain], rebase); + } + + private async persistDomains( + domains: readonly string[], + rebase: (stagedRaw: ResolvedConfig, stagedRawSnake: ResolvedConfig) => void, + ): Promise { + this.assertPersistable(); + let onDisk: ResolvedConfig = {}; + try { + const data = await this.documentStore.get(CONFIG_SCOPE, this.configKey); + onDisk = data !== undefined && isPlainObject(data) ? data : {}; + } catch (error) { + const message = + error instanceof TomlError + ? `Failed to parse ${this.bootstrap.configPath}: ${describeTomlSyntaxError(error)}` + : describeUnknownError(error); + this.pushDiagnostic({ severity: 'error', message }); + this.emitDiagnosticsIfChanged(); + this.log.warn('config persist aborted: re-read failed', { + error: describeUnknownError(error), + }); + this.tainted = true; + throw new Error2( + ErrorCodes.CONFIG_PERSIST_BLOCKED, + `Refusing to persist config: ${this.bootstrap.configPath} could not be read; fix the file and reload before writing.`, + { cause: error }, + ); + } + let onDiskText: string | undefined; + try { + onDiskText = await this.documentStore.getText(CONFIG_SCOPE, this.configKey); + } catch { + onDiskText = undefined; + } + const stagedRawSnake = cloneRecord(onDisk); + const stagedRaw = transformTomlData(onDisk, this.registry); + const previousSnake: ResolvedConfig = {}; + for (const domain of domains) { + const snakeKey = camelToSnake(domain); + previousSnake[snakeKey] = stagedRawSnake[snakeKey]; + } + rebase(stagedRaw, stagedRawSnake); + for (const domain of domains) { + applySectionToToml(stagedRawSnake, domain, stagedRaw[domain], this.registry); + } + const plannedText = + onDiskText === undefined + ? undefined + : planConfigWriteback( + onDiskText, + domains.map((domain) => { + const snakeKey = camelToSnake(domain); + return { + snakeKey, + previousValue: previousSnake[snakeKey], + nextValue: stagedRawSnake[snakeKey], + }; + }), + stagedRawSnake, + ); + if (plannedText === undefined) { + await this.documentStore.set(CONFIG_SCOPE, this.configKey, stagedRawSnake); + } else if (plannedText !== onDiskText) { + await this.documentStore.setText(CONFIG_SCOPE, this.configKey, plannedText); + } + this.rawSnake = stagedRawSnake; + this.raw = stagedRaw; + } +} + +registerScopedService( + LifecycleScope.App, + IConfigRegistry, + ConfigRegistry, + ScopeActivation.OnScopeCreated, + 'config', +); +registerScopedService( + LifecycleScope.App, + IConfigService, + ConfigService, + ScopeActivation.OnScopeCreated, + 'config', +); diff --git a/packages/agent-core-v2/src/app/config/deprecations.ts b/packages/agent-core-v2/src/app/config/deprecations.ts new file mode 100644 index 0000000000000000000000000000000000000000..c1296daa0958a366957cbd02eff9c580f2b8e216 --- /dev/null +++ b/packages/agent-core-v2/src/app/config/deprecations.ts @@ -0,0 +1,30 @@ +import type { ConfigDiagnostic, ConfigSection } from './config'; +import { isPlainObject } from './configPure'; +import { camelToSnake } from './toml'; + +export function collectKeyDeprecations( + rawSnake: Record, + sections: readonly ConfigSection[], +): ConfigDiagnostic[] { + const diagnostics: ConfigDiagnostic[] = []; + for (const section of sections) { + const deprecations = section.deprecations; + if (deprecations === undefined || deprecations.length === 0) continue; + const snakeDomain = camelToSnake(section.domain); + const rawSection = rawSnake[snakeDomain]; + if (!isPlainObject(rawSection)) continue; + for (const deprecation of deprecations) { + if (rawSection[deprecation.key] === undefined) continue; + diagnostics.push({ + domain: section.domain, + severity: 'warning', + message: + `[${snakeDomain}] '${deprecation.key}' is deprecated and no longer used; ` + + `rename it to '${deprecation.replacement}'.` + + (deprecation.message === undefined ? '' : ` ${deprecation.message}`) + + ' Run /update-config to fix it.', + }); + } + } + return diagnostics; +} diff --git a/packages/agent-core-v2/src/app/config/errors.ts b/packages/agent-core-v2/src/app/config/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..1af2a67f7b1aa461c4597c4b58d561b94ca83753 --- /dev/null +++ b/packages/agent-core-v2/src/app/config/errors.ts @@ -0,0 +1,11 @@ +import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; +import { CONFIG_INVALID_ERROR_CODE } from '#/llm-adapter/contract/errors'; + +export const ConfigErrors = { + codes: { + CONFIG_INVALID: CONFIG_INVALID_ERROR_CODE, + CONFIG_PERSIST_BLOCKED: 'config.persist_blocked', + }, +} as const satisfies ErrorDomain; + +registerErrorDomain(ConfigErrors); diff --git a/packages/agent-core-v2/src/app/config/sectionDiff.ts b/packages/agent-core-v2/src/app/config/sectionDiff.ts new file mode 100644 index 0000000000000000000000000000000000000000..210843d237447b72d68bb5ae25bdd880ebc0af4c --- /dev/null +++ b/packages/agent-core-v2/src/app/config/sectionDiff.ts @@ -0,0 +1,45 @@ +export interface RecordDiff { + readonly added: readonly string[]; + readonly removed: readonly string[]; + readonly changed: readonly string[]; +} + +export function diffRecords( + previous: Readonly> | undefined, + current: Readonly> | undefined, +): RecordDiff { + const prev = previous ?? {}; + const curr = current ?? {}; + const added: string[] = []; + const removed: string[] = []; + const changed: string[] = []; + for (const key of Object.keys(curr)) { + if (!(key in prev)) { + added.push(key); + } else if (!deepEqual(prev[key], curr[key])) { + changed.push(key); + } + } + for (const key of Object.keys(prev)) { + if (!(key in curr)) { + removed.push(key); + } + } + return { added, removed, changed }; +} + +export function deepEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true; + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; + if (Array.isArray(a) !== Array.isArray(b)) return false; + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) return false; + for (const key of aKeys) { + if (!Object.prototype.hasOwnProperty.call(b, key)) return false; + if (!deepEqual((a as Record)[key], (b as Record)[key])) { + return false; + } + } + return true; +} diff --git a/packages/agent-core-v2/src/app/config/toml.ts b/packages/agent-core-v2/src/app/config/toml.ts new file mode 100644 index 0000000000000000000000000000000000000000..a78770628ae60452a0c2e122def8a1b173bf5f10 --- /dev/null +++ b/packages/agent-core-v2/src/app/config/toml.ts @@ -0,0 +1,113 @@ +import { TomlError } from 'smol-toml'; + +import type { IConfigRegistry } from './config'; +import { describeUnknownError, isPlainObject } from './configPure'; + +export { TomlError }; +export { isPlainObject } from './configPure'; + +export function snakeToCamel(str: string): string { + return str.replaceAll(/_([a-z])/g, (_, ch: string) => ch.toUpperCase()); +} + +export function camelToSnake(str: string): string { + return str.replaceAll(/[A-Z]/g, (ch: string) => `_${ch.toLowerCase()}`); +} + +export function transformPlainObject(data: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(data)) { + out[snakeToCamel(key)] = value; + } + return out; +} + +export function plainObjectToToml(value: Record, raw: unknown): Record { + const out = cloneRecord(raw); + for (const [key, entry] of Object.entries(value)) { + setDefined(out, camelToSnake(key), entry); + } + return out; +} + +function defaultFromToml(value: unknown): unknown { + return isPlainObject(value) ? transformPlainObject(value) : value; +} + +export function transformTomlData( + data: Record, + registry: IConfigRegistry, +): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(data)) { + const domain = snakeToCamel(key); + const fromToml = registry.getSection(domain)?.fromToml; + result[domain] = fromToml === undefined ? defaultFromToml(value) : fromToml(value); + } + return result; +} + +export function applySectionToToml( + rawSnake: Record, + domain: string, + value: unknown, + registry: IConfigRegistry, +): void { + const snakeKey = camelToSnake(domain); + const toToml = registry.getSection(domain)?.toToml; + + if (value === undefined) { + delete rawSnake[snakeKey]; + return; + } + + if (toToml !== undefined) { + const rawSub = cloneRecord(rawSnake[snakeKey]); + const converted = toToml(value, rawSub); + if (converted === undefined || converted === null) { + delete rawSnake[snakeKey]; + } else if (isPlainObject(converted) && Object.keys(converted).length === 0) { + delete rawSnake[snakeKey]; + } else { + rawSnake[snakeKey] = converted; + } + return; + } + + if (!isPlainObject(value)) { + setDefined(rawSnake, snakeKey, value); + return; + } + const rawSub = cloneRecord(rawSnake[snakeKey]); + const converted = plainObjectToToml(value, rawSub); + if (Object.keys(converted).length > 0) { + rawSnake[snakeKey] = converted; + } else { + delete rawSnake[snakeKey]; + } +} + +export function describeTomlSyntaxError(error: unknown): string { + const firstLine = describeUnknownError(error).split('\n', 1)[0] ?? ''; + if (error instanceof TomlError) { + return `${firstLine} (line ${error.line}, column ${error.column})`; + } + return firstLine; +} + +export function cloneRecord(value: unknown): Record { + if (!isPlainObject(value)) return {}; + return cloneUnknown(value); +} + +function cloneUnknown(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +export function setDefined(target: Record, key: string, value: unknown): void { + if (value !== undefined) { + target[key] = value; + } else { + delete target[key]; + } +} diff --git a/packages/agent-core-v2/src/app/config/tomlWriteback.ts b/packages/agent-core-v2/src/app/config/tomlWriteback.ts new file mode 100644 index 0000000000000000000000000000000000000000..8b184084038e9d525e6aaaabbd65f03b4894d2bb --- /dev/null +++ b/packages/agent-core-v2/src/app/config/tomlWriteback.ts @@ -0,0 +1,753 @@ +import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'; + +import { deepEqual, isPlainObject } from './configPure'; + +export interface DomainUpdate { + readonly snakeKey: string; + readonly previousValue: unknown; + readonly nextValue: unknown; +} + +type LineEdit = + | { + readonly type: 'replace'; + readonly startLine: number; + readonly endLine: number; + readonly text: string; + } + | { readonly type: 'insert'; readonly afterLine: number; readonly text: string }; + +interface RootRegion { + readonly rootKey: string; + start: number; + end: number; + dotted: boolean; +} + +type RootSegment = + | { readonly kind: 'trivia'; readonly start: number; readonly end: number } + | { readonly kind: 'region'; readonly region: RootRegion }; + +interface DomainStatement { + readonly key: string; + readonly startLine: number; + readonly endLine: number; + readonly indent: string; + readonly separator: string; + readonly valueStart: number; + readonly valueEnd: number; +} + +interface DomainBlock { + readonly path: readonly string[]; + readonly hasHeader: boolean; + readonly isArray: boolean; + readonly startLine: number; + endLine: number; + readonly statements: DomainStatement[]; +} + +interface DomainScan { + readonly blocks: readonly DomainBlock[]; + readonly ambiguous: boolean; +} + +interface KeyValueMatch { + readonly indent: string; + readonly keySegments: readonly string[]; + readonly dotted: boolean; + readonly separator: string; + readonly valueStart: number; +} + +interface HeaderMatch { + readonly rootKey: string; + readonly path: readonly string[]; + readonly isArray: boolean; +} + +const KEY_VALUE_LINE_PATTERN = /^(\s*)([A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*)(\s*=\s*)([\s\S]*)$/; +const BARE_KEY_CHAR_PATTERN = /[A-Za-z0-9_-]/; + +function splitLinesKeepEnds(text: string): string[] { + const lines: string[] = []; + let start = 0; + for (let i = 0; i < text.length; i++) { + if (text[i] === '\n') { + lines.push(text.slice(start, i + 1)); + start = i + 1; + } + } + if (start < text.length) lines.push(text.slice(start)); + return lines; +} + +function stripLineEnding(line: string): string { + if (!line.endsWith('\n')) return line; + return line.endsWith('\r\n') ? line.slice(0, -2) : line.slice(0, -1); +} + +function detectEol(text: string): string { + const index = text.indexOf('\n'); + return index > 0 && text.charAt(index - 1) === '\r' ? '\r\n' : '\n'; +} + +function isTriviaBody(body: string): boolean { + const trimmed = body.trim(); + return trimmed.length === 0 || trimmed.startsWith('#'); +} + +function lineIndexAt(offsets: readonly number[], position: number): number { + let low = 0; + let high = offsets.length - 1; + let result = 0; + while (low <= high) { + const mid = (low + high) >> 1; + if (offsets[mid]! <= position) { + result = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + return result; +} + +function lineStartOffsets(lines: readonly string[]): number[] { + const offsets: number[] = []; + let offset = 0; + for (const line of lines) { + offsets.push(offset); + offset += line.length; + } + return offsets; +} + +function scanStringEnd(text: string, offset: number): number | undefined { + const quote = text.charAt(offset); + if (text.startsWith(quote + quote + quote, offset)) { + let i = offset + 3; + while (i < text.length) { + if (quote === '"' && text.charAt(i) === '\\') { + i += 2; + continue; + } + if (text.charAt(i) === quote) { + let run = 0; + while (i + run < text.length && text.charAt(i + run) === quote) run++; + if (run >= 3) return i + run; + i += run; + continue; + } + i++; + } + return undefined; + } + let i = offset + 1; + while (i < text.length) { + if (text.charAt(i) === '\n') return undefined; + if (quote === '"' && text.charAt(i) === '\\') { + i += 2; + continue; + } + if (text.charAt(i) === quote) return i + 1; + i++; + } + return undefined; +} + +function scanBalanced(text: string, offset: number, open: string, close: string): number | undefined { + let depth = 0; + let i = offset; + while (i < text.length) { + const ch = text.charAt(i); + if (ch === '"' || ch === "'") { + const end = scanStringEnd(text, i); + if (end === undefined) return undefined; + i = end; + continue; + } + if (ch === open) { + depth++; + } else if (ch === close) { + depth--; + if (depth === 0) return i + 1; + } else if (ch === '\n' && open === '{') { + return undefined; + } + i++; + } + return undefined; +} + +function scanValueEnd(text: string, offset: number): number | undefined { + const first = text.charAt(offset); + if (first === '"' || first === "'") return scanStringEnd(text, offset); + if (first === '[') return scanBalanced(text, offset, '[', ']'); + if (first === '{') return scanBalanced(text, offset, '{', '}'); + let i = offset; + while (i < text.length) { + const ch = text.charAt(i); + if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n' || ch === '#') break; + i++; + } + return i === offset ? undefined : i; +} + +function decodeBasicEscape(body: string, offset: number): { char: string; end: number } | undefined { + const code = body.charAt(offset + 1); + switch (code) { + case 'b': + return { char: '\b', end: offset + 2 }; + case 't': + return { char: '\t', end: offset + 2 }; + case 'n': + return { char: '\n', end: offset + 2 }; + case 'f': + return { char: '\f', end: offset + 2 }; + case 'r': + return { char: '\r', end: offset + 2 }; + case '"': + return { char: '"', end: offset + 2 }; + case '\\': + return { char: '\\', end: offset + 2 }; + case 'u': + return decodeUnicodeEscape(body, offset, 4); + case 'U': + return decodeUnicodeEscape(body, offset, 8); + default: + return undefined; + } +} + +function decodeUnicodeEscape( + body: string, + offset: number, + digits: number, +): { char: string; end: number } | undefined { + const hex = body.slice(offset + 2, offset + 2 + digits); + if (hex.length !== digits || !/^[0-9a-fA-F]+$/.test(hex)) return undefined; + const codePoint = Number.parseInt(hex, 16); + if (codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) return undefined; + return { char: String.fromCodePoint(codePoint), end: offset + 2 + digits }; +} + +function skipInlineWhitespace(body: string, offset: number): number { + let i = offset; + while (i < body.length) { + const ch = body.charAt(i); + if (ch !== ' ' && ch !== '\t') break; + i++; + } + return i; +} + +interface HeaderSegment { + readonly value: string; + readonly end: number; +} + +function scanBasicHeaderSegment(body: string, offset: number): HeaderSegment | undefined { + let i = offset + 1; + let value = ''; + while (i < body.length) { + const ch = body.charAt(i); + if (ch === '"') { + if (value.length === 0) return undefined; + return { value, end: i + 1 }; + } + if (ch === '\\') { + const escape = decodeBasicEscape(body, i); + if (escape === undefined) return undefined; + value += escape.char; + i = escape.end; + continue; + } + value += ch; + i++; + } + return undefined; +} + +function scanLiteralHeaderSegment(body: string, offset: number): HeaderSegment | undefined { + const close = body.indexOf("'", offset + 1); + if (close === -1) return undefined; + const value = body.slice(offset + 1, close); + if (value.length === 0) return undefined; + return { value, end: close + 1 }; +} + +function scanHeaderSegment(body: string, offset: number): HeaderSegment | undefined { + const start = skipInlineWhitespace(body, offset); + const ch = body.charAt(start); + if (ch === '"') return scanBasicHeaderSegment(body, start); + if (ch === "'") return scanLiteralHeaderSegment(body, start); + let i = start; + while (i < body.length && BARE_KEY_CHAR_PATTERN.test(body.charAt(i))) i++; + if (i === start) return undefined; + return { value: body.slice(start, i), end: i }; +} + +function matchHeader(body: string): HeaderMatch | undefined { + const start = skipInlineWhitespace(body, 0); + let isArray = false; + let i: number; + if (body.startsWith('[[', start)) { + isArray = true; + i = start + 2; + } else if (body.charAt(start) === '[') { + i = start + 1; + } else { + return undefined; + } + const path: string[] = []; + for (;;) { + const segment = scanHeaderSegment(body, i); + if (segment === undefined) return undefined; + path.push(segment.value); + i = skipInlineWhitespace(body, segment.end); + const ch = body.charAt(i); + if (ch === ']') { + i++; + break; + } + if (ch !== '.') return undefined; + i = skipInlineWhitespace(body, i + 1); + } + if (isArray) { + if (body.charAt(i) !== ']') return undefined; + i++; + } + const rest = skipInlineWhitespace(body, i); + if (rest < body.length && body.charAt(rest) !== '#') return undefined; + return { rootKey: path[0]!, path, isArray }; +} + +function matchKeyValue(body: string): KeyValueMatch | undefined { + const match = KEY_VALUE_LINE_PATTERN.exec(body); + if (match === null) return undefined; + const keySegments = match[2]!.split('.'); + return { + indent: match[1]!, + keySegments, + dotted: keySegments.length > 1, + separator: match[3]!, + valueStart: body.length - match[4]!.length, + }; +} + +interface ScannedDocument { + lines: string[]; + offsets: number[]; + eol: string; + segments: RootSegment[]; +} + +function scanRootRegions(text: string): ScannedDocument | undefined { + const lines = splitLinesKeepEnds(text); + const offsets = lineStartOffsets(lines); + const eol = detectEol(text); + const segments: RootSegment[] = []; + let region: RootRegion | undefined; + let triviaStart = -1; + let i = 0; + while (i < lines.length) { + const body = stripLineEnding(lines[i]!); + if (isTriviaBody(body)) { + if (triviaStart < 0) triviaStart = i; + i++; + continue; + } + if (triviaStart >= 0) { + segments.push({ kind: 'trivia', start: triviaStart, end: i - 1 }); + triviaStart = -1; + } + const header = matchHeader(body); + if (header !== undefined) { + if (region === undefined || region.rootKey !== header.rootKey) { + if (region !== undefined) segments.push({ kind: 'region', region }); + region = { rootKey: header.rootKey, start: i, end: i, dotted: false }; + } else { + region.end = i; + } + i++; + continue; + } + const kv = matchKeyValue(body); + if (kv === undefined) return undefined; + const valueStart = offsets[i]! + kv.valueStart; + const valueEnd = scanValueEnd(text, valueStart); + if (valueEnd === undefined) return undefined; + const endLine = lineIndexAt(offsets, valueEnd - 1); + const rootKey = region === undefined ? kv.keySegments[0]! : region.rootKey; + if (region === undefined || region.rootKey !== rootKey) { + if (region !== undefined) segments.push({ kind: 'region', region }); + region = { rootKey, start: i, end: endLine, dotted: kv.dotted }; + } else { + region.end = endLine; + region.dotted = region.dotted || kv.dotted; + } + i = endLine + 1; + } + if (triviaStart >= 0) segments.push({ kind: 'trivia', start: triviaStart, end: lines.length - 1 }); + if (region !== undefined) segments.push({ kind: 'region', region }); + return { lines, offsets, eol, segments }; +} + +function scanDomainRegion( + text: string, + lines: readonly string[], + offsets: readonly number[], + region: RootRegion, + snakeKey: string, +): DomainScan | undefined { + const blocks: DomainBlock[] = []; + let ambiguous = false; + let current: DomainBlock | undefined; + for (let i = region.start; i <= region.end; i++) { + const body = stripLineEnding(lines[i]!); + if (isTriviaBody(body)) continue; + const header = matchHeader(body); + if (header !== undefined) { + if (header.rootKey !== snakeKey) return undefined; + current = { + path: header.path.slice(1), + hasHeader: true, + isArray: header.isArray, + startLine: i, + endLine: i, + statements: [], + }; + if (header.isArray) ambiguous = true; + blocks.push(current); + continue; + } + const kv = matchKeyValue(body); + if (kv === undefined) return undefined; + if (kv.dotted) ambiguous = true; + const valueStart = offsets[i]! + kv.valueStart; + const valueEnd = scanValueEnd(text, valueStart); + if (valueEnd === undefined) return undefined; + const endLine = lineIndexAt(offsets, valueEnd - 1); + const statement: DomainStatement = { + key: kv.keySegments.at(-1)!, + startLine: i, + endLine, + indent: kv.indent, + separator: kv.separator, + valueStart, + valueEnd, + }; + if (current === undefined) { + current = { + path: [], + hasHeader: false, + isArray: false, + startLine: i, + endLine, + statements: [statement], + }; + blocks.push(current); + } else { + current.statements.push(statement); + current.endLine = endLine; + } + i = endLine; + } + return { blocks, ambiguous }; +} + +function pathsEqual(a: readonly string[], b: readonly string[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +function blocksNestedUnder(blocks: readonly DomainBlock[], path: readonly string[]): readonly DomainBlock[] { + return blocks.filter( + (block) => block.path.length >= path.length && pathsEqual(block.path.slice(0, path.length), path), + ); +} + +function removeLines(startLine: number, endLine: number): LineEdit { + return { type: 'replace', startLine, endLine, text: '' }; +} + +function insertMerged(edits: LineEdit[], afterLine: number, text: string): void { + const existing = edits.find((edit) => edit.type === 'insert' && edit.afterLine === afterLine); + if (existing !== undefined && existing.type === 'insert') { + edits.splice(edits.indexOf(existing), 1, { ...existing, text: existing.text + text }); + return; + } + edits.push({ type: 'insert', afterLine, text }); +} + +function statementSuffix(text: string, statement: DomainStatement): string { + const lineEnd = text.indexOf('\n', statement.valueEnd); + const end = lineEnd === -1 ? text.length : lineEnd; + return text.slice(statement.valueEnd, end).replace(/\r$/, ''); +} + +function renderStatement(text: string, statement: DomainStatement, valueText: string, eol: string): string { + const suffix = statementSuffix(text, statement); + const rendered = `${statement.indent}${statement.key}${statement.separator}${valueText}${suffix}`; + return rendered.endsWith('\n') ? rendered : `${rendered}${eol}`; +} + +function serializeValueText(key: string, value: unknown): string | undefined { + const prefix = `${key} = `; + const serialized = stringifyToml({ [key]: value }); + if (!serialized.startsWith(prefix)) return undefined; + const text = serialized.slice(prefix.length); + return text.endsWith('\n') ? text.slice(0, -1) : text; +} + +function serializeTableBlock(path: readonly string[], value: unknown, eol: string): string { + let nested: unknown = value; + for (let i = path.length - 1; i >= 0; i--) { + nested = { [path[i]!]: nested }; + } + return stringifyToml(nested as Record).replaceAll('\n', eol); +} + +function blockAnchorLine(block: DomainBlock): number { + const last = block.statements.at(-1); + return last === undefined ? block.startLine : last.endLine; +} + +function planScalarDomainEdit( + text: string, + scan: DomainScan, + update: DomainUpdate, + eol: string, +): LineEdit[] | undefined { + const block = scan.blocks[0]; + if ( + block === undefined || + scan.blocks.length !== 1 || + block.hasHeader || + block.path.length > 0 || + block.statements.length !== 1 + ) { + return undefined; + } + const statement = block.statements[0]!; + if (statement.key !== update.snakeKey) return undefined; + const valueText = serializeValueText(statement.key, update.nextValue); + if (valueText === undefined) return undefined; + return [ + { + type: 'replace', + startLine: statement.startLine, + endLine: statement.endLine, + text: renderStatement(text, statement, valueText, eol), + }, + ]; +} + +function planObjectLevel( + text: string, + rootKey: string, + blocks: readonly DomainBlock[], + prefix: readonly string[], + previousValue: Record, + nextValue: Record, + edits: LineEdit[], + appends: string[], + eol: string, +): boolean { + const block = blocks.find((candidate) => pathsEqual(candidate.path, prefix)); + const keys = [...new Set([...Object.keys(previousValue), ...Object.keys(nextValue)])]; + for (const key of keys) { + const previous = previousValue[key]; + const next = nextValue[key]; + if (deepEqual(previous, next)) continue; + const childPath = [...prefix, key]; + const statement = block?.statements.find((candidate) => candidate.key === key); + const childBlock = blocks.find((candidate) => pathsEqual(candidate.path, childPath)); + if (statement !== undefined && childBlock !== undefined) return false; + if (next === undefined) { + if (childBlock !== undefined) { + if (childBlock.isArray) return false; + for (const nested of blocksNestedUnder(blocks, childPath)) { + edits.push(removeLines(nested.startLine, nested.endLine)); + } + continue; + } + if (statement === undefined) return false; + edits.push(removeLines(statement.startLine, statement.endLine)); + continue; + } + if (previous === undefined) { + if (isPlainObject(next)) { + appends.push(serializeTableBlock([rootKey, ...childPath], next, eol)); + } else { + const valueText = serializeValueText(key, next); + if (valueText === undefined) return false; + if (block !== undefined) { + insertMerged(edits, blockAnchorLine(block), `${key} = ${valueText}${eol}`); + } else { + appends.push(serializeTableBlock([rootKey, ...prefix], { [key]: next }, eol)); + } + } + continue; + } + if (isPlainObject(previous) && isPlainObject(next)) { + if (statement !== undefined || childBlock === undefined || childBlock.isArray) return false; + if (!planObjectLevel(text, rootKey, blocks, childPath, previous, next, edits, appends, eol)) { + return false; + } + continue; + } + if (isPlainObject(next)) { + if (statement === undefined) return false; + edits.push(removeLines(statement.startLine, statement.endLine)); + appends.push(serializeTableBlock([rootKey, ...childPath], next, eol)); + continue; + } + if (isPlainObject(previous)) { + if (childBlock === undefined || childBlock.isArray) return false; + for (const nested of blocksNestedUnder(blocks, childPath)) { + edits.push(removeLines(nested.startLine, nested.endLine)); + } + const valueText = serializeValueText(key, next); + if (valueText === undefined) return false; + if (block !== undefined) { + insertMerged(edits, blockAnchorLine(block), `${key} = ${valueText}${eol}`); + } else { + appends.push(serializeTableBlock([rootKey, ...prefix], { [key]: next }, eol)); + } + continue; + } + if (statement === undefined) return false; + const valueText = serializeValueText(key, next); + if (valueText === undefined) return false; + edits.push({ + type: 'replace', + startLine: statement.startLine, + endLine: statement.endLine, + text: renderStatement(text, statement, valueText, eol), + }); + } + return true; +} + +function planDomainKeyEdit( + text: string, + scan: DomainScan, + region: RootRegion, + update: DomainUpdate, + eol: string, +): LineEdit[] | undefined { + if (scan.ambiguous) return undefined; + const previousValue = update.previousValue; + const nextValue = update.nextValue; + if (!isPlainObject(previousValue) || !isPlainObject(nextValue)) { + if (isPlainObject(previousValue) || isPlainObject(nextValue)) return undefined; + return planScalarDomainEdit(text, scan, update, eol); + } + const edits: LineEdit[] = []; + const appends: string[] = []; + if (!planObjectLevel(text, update.snakeKey, scan.blocks, [], previousValue, nextValue, edits, appends, eol)) { + return undefined; + } + if (appends.length > 0) { + edits.push({ type: 'insert', afterLine: region.end, text: appends.join('') }); + } + return edits; +} + +function editPosition(edit: LineEdit): number { + return edit.type === 'replace' ? edit.startLine : edit.afterLine + 0.5; +} + +function applyLineEdits(lines: readonly string[], edits: readonly LineEdit[], eol: string): string { + const ordered = edits.toSorted((a, b) => editPosition(b) - editPosition(a)); + const out = [...lines]; + for (const edit of ordered) { + if (edit.type === 'replace') { + out.splice(edit.startLine, edit.endLine - edit.startLine + 1, ...splitLinesKeepEnds(edit.text)); + } else { + const prefix = edit.afterLine < out.length && !out[edit.afterLine]!.endsWith('\n') ? eol : ''; + out.splice(edit.afterLine + 1, 0, ...splitLinesKeepEnds(prefix + edit.text)); + } + } + return out.join(''); +} + +function verifyPlannedText(text: string, expected: Record): boolean { + if (text.trim().length === 0) return Object.keys(expected).length === 0; + try { + return deepEqual(parseToml(text), expected); + } catch { + return false; + } +} + +export function planConfigWriteback( + originalText: string, + updates: readonly DomainUpdate[], + expected: Record, +): string | undefined { + const scanned = scanRootRegions(originalText); + if (scanned === undefined) return undefined; + const regionsByKey = new Map(); + for (const segment of scanned.segments) { + if (segment.kind !== 'region') continue; + const list = regionsByKey.get(segment.region.rootKey); + if (list === undefined) { + regionsByKey.set(segment.region.rootKey, [segment.region]); + } else { + list.push(segment.region); + } + } + const edits: LineEdit[] = []; + const appends: string[] = []; + for (const update of updates) { + if (deepEqual(update.previousValue, update.nextValue)) continue; + const regions = regionsByKey.get(update.snakeKey) ?? []; + if (update.previousValue === undefined && regions.length > 0) return undefined; + if (update.nextValue === undefined) { + if (update.previousValue === undefined) continue; + if (regions.length === 0) return undefined; + for (const region of regions) edits.push(removeLines(region.start, region.end)); + continue; + } + if (regions.length === 0) { + if (update.previousValue !== undefined) return undefined; + appends.push(serializeTableBlock([update.snakeKey], update.nextValue, scanned.eol)); + continue; + } + const replacement = serializeTableBlock([update.snakeKey], update.nextValue, scanned.eol); + if (regions.length > 1 || regions[0]!.dotted) { + edits.push({ + type: 'replace', + startLine: regions[0]!.start, + endLine: regions[0]!.end, + text: replacement, + }); + for (const extra of regions.slice(1)) edits.push(removeLines(extra.start, extra.end)); + continue; + } + const region = regions[0]!; + const scan = scanDomainRegion(originalText, scanned.lines, scanned.offsets, region, update.snakeKey); + const planned = + scan === undefined + ? undefined + : planDomainKeyEdit(originalText, scan, region, update, scanned.eol); + if (planned === undefined) { + edits.push({ type: 'replace', startLine: region.start, endLine: region.end, text: replacement }); + continue; + } + edits.push(...planned); + } + let text = applyLineEdits(scanned.lines, edits, scanned.eol); + for (const block of appends) { + if (text.length > 0 && !text.endsWith('\n')) text += scanned.eol; + text += block; + } + if (!verifyPlannedText(text, expected)) return undefined; + return text; +} diff --git a/packages/agent-core-v2/src/app/edit/editService.ts b/packages/agent-core-v2/src/app/edit/editService.ts new file mode 100644 index 0000000000000000000000000000000000000000..3c261d4c9c6c95018cc2b8ea0a43e326c9c770f6 --- /dev/null +++ b/packages/agent-core-v2/src/app/edit/editService.ts @@ -0,0 +1,41 @@ +import type { TextModel } from './textModel'; + +export interface EditApplyInput { + readonly path: string; + readonly old_string: string; + readonly new_string: string; + readonly replace_all: boolean; +} + +export type EditApplyResult = + | { readonly ok: true; readonly rawContent: string; readonly count: number } + | { readonly ok: false; readonly error: string }; + +function notFoundMessage(path: string): string { + return `old_string not found in ${path}, the file contents may be out of date. Please use the Read Tool to reload the content. +`; +} + +function notUniqueMessage(path: string, count: number): string { + return ( + `old_string is not unique in ${path} (found ${String(count)} occurrences). ` + + 'To replace every occurrence, set replace_all=true. To replace only one occurrence, include more surrounding context in old_string.' + ); +} + +export class EditService { + apply(model: TextModel, input: EditApplyInput): EditApplyResult { + if (input.replace_all) { + const { text, count } = model.replaceAll(input.old_string, input.new_string); + if (count === 0) return { ok: false, error: notFoundMessage(input.path) }; + return { ok: true, rawContent: model.materialize(text), count }; + } + + const count = model.countOccurrences(input.old_string); + if (count === 0) return { ok: false, error: notFoundMessage(input.path) }; + if (count > 1) return { ok: false, error: notUniqueMessage(input.path, count) }; + + const text = model.replaceOnce(input.old_string, input.new_string); + return { ok: true, rawContent: model.materialize(text), count: 1 }; + } +} diff --git a/packages/agent-core-v2/src/app/edit/fileEdit.ts b/packages/agent-core-v2/src/app/edit/fileEdit.ts new file mode 100644 index 0000000000000000000000000000000000000000..b2b10cc7274e9b8f78e66ed8f6839eb70dd00df0 --- /dev/null +++ b/packages/agent-core-v2/src/app/edit/fileEdit.ts @@ -0,0 +1,23 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; + +export interface FileEditInput { + readonly path: string; + readonly displayPath: string; + readonly old_string: string; + readonly new_string: string; + readonly replace_all: boolean; +} + +export type FileEditResult = + | { readonly ok: true; readonly count: number } + | { readonly ok: false; readonly error: string }; + +export interface IFileEditService { + readonly _serviceBrand: undefined; + + edit(input: FileEditInput, fs?: IHostFileSystem): Promise; +} + +export const IFileEditService: ServiceIdentifier = + createDecorator('fileEditService'); diff --git a/packages/agent-core-v2/src/app/edit/fileEditService.ts b/packages/agent-core-v2/src/app/edit/fileEditService.ts new file mode 100644 index 0000000000000000000000000000000000000000..79e8510248ee901478ad1f2ec4d23b2df82615e9 --- /dev/null +++ b/packages/agent-core-v2/src/app/edit/fileEditService.ts @@ -0,0 +1,54 @@ +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { unwrapErrorCause } from '#/_base/errors/errors'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; + +import { EditService } from './editService'; +import { type FileEditInput, type FileEditResult, IFileEditService } from './fileEdit'; +import { TextModel } from './textModel'; + +export class FileEditService implements IFileEditService { + declare readonly _serviceBrand: undefined; + + private readonly editor: EditService; + + constructor(@IHostFileSystem private readonly fs: IHostFileSystem) { + this.editor = new EditService(); + } + + async edit(input: FileEditInput, fs: IHostFileSystem = this.fs): Promise { + try { + const raw = await fs.readText(input.path, { errors: 'strict' }); + const model = new TextModel(raw); + const result = this.editor.apply(model, { + path: input.displayPath, + old_string: input.old_string, + new_string: input.new_string, + replace_all: input.replace_all, + }); + if (!result.ok) { + return { ok: false, error: result.error }; + } + await fs.writeText(input.path, result.rawContent); + return { ok: true, count: result.count }; + } catch (error) { + const code = (unwrapErrorCause(error) as { code?: unknown } | null)?.code; + if (code === 'EISDIR') { + return { ok: false, error: `${input.displayPath} is not a file.` }; + } + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } +} + +registerScopedService( + LifecycleScope.App, + IFileEditService, + FileEditService, + ScopeActivation.OnScopeCreated, + 'edit', +); diff --git a/packages/agent-core-v2/src/app/edit/textModel.ts b/packages/agent-core-v2/src/app/edit/textModel.ts new file mode 100644 index 0000000000000000000000000000000000000000..ab6611d738f357a66b7e584c0ec550793b7c6238 --- /dev/null +++ b/packages/agent-core-v2/src/app/edit/textModel.ts @@ -0,0 +1,43 @@ +import { + type LineEndingStyle, + materializeModelText, + toModelTextView, +} from '#/_base/text/line-endings'; + +export class TextModel { + readonly lineEndingStyle: LineEndingStyle; + readonly text: string; + + constructor(raw: string) { + const view = toModelTextView(raw); + this.text = view.text; + this.lineEndingStyle = view.lineEndingStyle; + } + + countOccurrences(needle: string): number { + let count = 0; + let pos = 0; + while (pos < this.text.length) { + const idx = this.text.indexOf(needle, pos); + if (idx === -1) break; + count += 1; + pos = idx + needle.length; + } + return count; + } + + replaceOnce(needle: string, replacement: string): string { + const index = this.text.indexOf(needle); + if (index === -1) return this.text; + return this.text.slice(0, index) + replacement + this.text.slice(index + needle.length); + } + + replaceAll(needle: string, replacement: string): { text: string; count: number } { + const parts = this.text.split(needle); + return { text: parts.join(replacement), count: parts.length - 1 }; + } + + materialize(modelText: string): string { + return materializeModelText(modelText, this.lineEndingStyle); + } +} diff --git a/packages/agent-core-v2/src/app/file/fileService.ts b/packages/agent-core-v2/src/app/file/fileService.ts new file mode 100644 index 0000000000000000000000000000000000000000..71ee5ef6466c9faa21fa765b0e6155de1a0f1ee5 --- /dev/null +++ b/packages/agent-core-v2/src/app/file/fileService.ts @@ -0,0 +1,85 @@ +import type { Readable } from 'node:stream'; + +import { z } from 'zod'; + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; +import { Error2 } from '#/_base/errors/errors'; +import { isoDateTimeSchema } from '#/_base/utils/isoDateTime'; + +export const fileMetaSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + media_type: z.string().min(1), + size: z.number().int().nonnegative(), + created_at: isoDateTimeSchema, + expires_at: isoDateTimeSchema.optional(), +}); +export type FileMeta = z.infer; + +export interface SaveOptions { + readonly name?: string; + readonly mimeType?: string; + readonly expiresInSec?: number; +} + +export interface GetResult { + readonly meta: FileMeta; + readonly stream: (range?: FileReadRange) => Readable; +} + +export interface FileReadRange { + readonly start: number; + readonly end: number; +} + +export interface IFileService { + readonly _serviceBrand: undefined; + + save(source: Readable, filename: string, options?: SaveOptions): Promise; + get(fileId: string): Promise; + delete(fileId: string): Promise; +} + +export const IFileService: ServiceIdentifier = createDecorator('fileService'); + +export const FILE_ID_REGEX = /^f_[A-Za-z0-9][A-Za-z0-9_-]*$/; + +export function isFileId(value: string): boolean { + return FILE_ID_REGEX.test(value); +} + +export const FileErrors = { + codes: { + FILE_NOT_FOUND: 'file.not_found', + }, + info: { + 'file.not_found': { + title: 'File not found', + retryable: false, + public: true, + action: 'Check the file_id or upload the file again.', + }, + }, +} as const satisfies ErrorDomain; + +registerErrorDomain(FileErrors); + +export class FileError extends Error2 { + constructor( + code: (typeof FileErrors.codes)[keyof typeof FileErrors.codes], + message: string, + details?: Record, + ) { + super(code, message, { details }); + this.name = 'FileError'; + } +} + +export function fileNotFoundError(fileId: string): FileError { + return new FileError(FileErrors.codes.FILE_NOT_FOUND, `file not found: ${fileId}`, { fileId }); +} + +export function isFileError(error: unknown, code: (typeof FileErrors.codes)[keyof typeof FileErrors.codes]): boolean { + return error instanceof Error2 && error.code === code; +} diff --git a/packages/agent-core-v2/src/app/file/fileServiceImpl.ts b/packages/agent-core-v2/src/app/file/fileServiceImpl.ts new file mode 100644 index 0000000000000000000000000000000000000000..dacab17c0d48bdfb95fd86d2aba4e92527c25eb1 --- /dev/null +++ b/packages/agent-core-v2/src/app/file/fileServiceImpl.ts @@ -0,0 +1,195 @@ +import { randomUUID } from 'node:crypto'; +import { Readable } from 'node:stream'; + +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IBlobStore } from '#/persistence/interface/blobStore'; +import { + IFileService, + fileNotFoundError, + isFileId, + type FileMeta, + type FileReadRange, + type GetResult, + type SaveOptions, +} from './fileService'; + +const BLOB_SCOPE = 'files'; +const INDEX_SCOPE = 'file'; +const INDEX_KEY = 'index.json'; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +interface IndexFile { + readonly version: 1; + readonly files: FileMeta[]; +} + +function isFileMeta(value: unknown): value is FileMeta { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const meta = value as Record; + return ( + typeof meta['id'] === 'string' && + isFileId(meta['id']) && + typeof meta['name'] === 'string' && + typeof meta['media_type'] === 'string' && + typeof meta['size'] === 'number' && + Number.isSafeInteger(meta['size']) && + meta['size'] >= 0 && + typeof meta['created_at'] === 'string' && + (meta['expires_at'] === undefined || typeof meta['expires_at'] === 'string') + ); +} + +export class FileServiceImpl implements IFileService { + declare readonly _serviceBrand: undefined; + + private indexCache: Map | undefined; + private indexLoadPromise: Promise | undefined; + private indexWritePromise: Promise = Promise.resolve(); + + constructor(@IBlobStore private readonly blobs: IBlobStore) {} + + async save(source: Readable, filename: string, options: SaveOptions = {}): Promise { + await this.ensureIndex(); + await this.pruneExpired(); + + const id = `f_${randomUUID()}`; + let size = 0; + const counting = async function* (): AsyncIterable { + for await (const chunk of source) { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string); + size += buf.length; + yield buf; + } + }; + try { + await this.blobs.putStream(BLOB_SCOPE, id, counting()); + } catch (error) { + await this.blobs.delete(BLOB_SCOPE, id).catch(() => undefined); + throw error; + } + + const now = Date.now(); + const meta: FileMeta = { + id, + name: options.name ?? filename, + media_type: options.mimeType ?? 'application/octet-stream', + size, + created_at: new Date(now).toISOString(), + expires_at: + options.expiresInSec === undefined + ? undefined + : new Date(now + options.expiresInSec * 1000).toISOString(), + }; + + this.indexCache!.set(id, meta); + await this.writeIndex(); + return meta; + } + + async get(fileId: string): Promise { + if (!isFileId(fileId)) { + throw fileNotFoundError(fileId); + } + await this.ensureIndex(); + await this.pruneExpired(); + const meta = this.indexCache!.get(fileId); + if (meta === undefined) { + throw fileNotFoundError(fileId); + } + + const present = await this.blobs.has(BLOB_SCOPE, fileId); + if (!present) { + this.indexCache!.delete(fileId); + await this.writeIndex(); + throw fileNotFoundError(fileId); + } + + return { + meta, + stream: (range?: FileReadRange) => + Readable.from(this.blobs.getStream(BLOB_SCOPE, fileId, range)), + }; + } + + async delete(fileId: string): Promise { + if (!isFileId(fileId)) { + throw fileNotFoundError(fileId); + } + await this.ensureIndex(); + if (!this.indexCache!.has(fileId)) { + throw fileNotFoundError(fileId); + } + this.indexCache!.delete(fileId); + await this.blobs.delete(BLOB_SCOPE, fileId); + await this.writeIndex(); + } + + private ensureIndex(): Promise { + if (this.indexCache !== undefined) return Promise.resolve(); + if (this.indexLoadPromise !== undefined) return this.indexLoadPromise; + this.indexLoadPromise = this.loadIndex().finally(() => { + this.indexLoadPromise = undefined; + }); + return this.indexLoadPromise; + } + + private async loadIndex(): Promise { + const raw = await this.blobs.get(INDEX_SCOPE, INDEX_KEY); + if (raw === undefined) { + this.indexCache = new Map(); + return; + } + try { + const parsed = JSON.parse(textDecoder.decode(raw)) as IndexFile; + const map = new Map(); + if (parsed && Array.isArray(parsed.files)) { + for (const f of parsed.files) { + if (isFileMeta(f)) { + map.set(f.id, f); + } + } + } + this.indexCache = map; + await this.pruneExpired(); + } catch { + this.indexCache = new Map(); + } + } + + private async pruneExpired(): Promise { + const cache = this.indexCache; + if (cache === undefined) return; + const now = Date.now(); + const expired = [...cache.values()].filter( + (meta) => meta.expires_at !== undefined && Date.parse(meta.expires_at) <= now, + ); + if (expired.length === 0) return; + for (const meta of expired) { + cache.delete(meta.id); + await this.blobs.delete(BLOB_SCOPE, meta.id).catch(() => undefined); + } + await this.writeIndex().catch(() => undefined); + } + + private async writeIndex(): Promise { + const write = this.indexWritePromise.catch(() => undefined).then(async () => { + const cache = this.indexCache; + if (cache === undefined) return; + const payload: IndexFile = { version: 1, files: Array.from(cache.values()) }; + await this.blobs.put(INDEX_SCOPE, INDEX_KEY, textEncoder.encode(JSON.stringify(payload))); + }); + this.indexWritePromise = write; + await write; + } +} + +registerScopedService( + LifecycleScope.App, + IFileService, + FileServiceImpl, + ScopeActivation.OnScopeCreated, + 'file', +); diff --git a/packages/agent-core-v2/src/app/flag/flag.ts b/packages/agent-core-v2/src/app/flag/flag.ts new file mode 100644 index 0000000000000000000000000000000000000000..46a44d84af4fdff1ec09a30a2981aa5e3f9b7816 --- /dev/null +++ b/packages/agent-core-v2/src/app/flag/flag.ts @@ -0,0 +1,64 @@ +import { z } from 'zod'; + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { registerConfigSection } from '#/app/config/configSectionContributions'; +import { cloneRecord, isPlainObject, setDefined } from '#/app/config/toml'; + +import type { FlagId, FlagSurface, IFlagRegistry } from './flagRegistry'; + +export type ExperimentalFlagMap = Record; + +export type ExperimentalFlagConfig = Partial>; + +export const EXPERIMENTAL_SECTION = 'experimental'; + +export const ExperimentalConfigSchema = z.record(z.string(), z.boolean()); + +export type ExperimentalConfig = z.infer; + +export const experimentalFromToml = (rawSnake: unknown): unknown => + isPlainObject(rawSnake) ? cloneRecord(rawSnake) : rawSnake; + +export const experimentalToToml = (value: unknown, _rawSnake: unknown): unknown => { + if (!isPlainObject(value)) return value; + const out: Record = {}; + for (const [key, entry] of Object.entries(value)) { + setDefined(out, key, entry); + } + return out; +}; + +registerConfigSection(EXPERIMENTAL_SECTION, ExperimentalConfigSchema, { + fromToml: experimentalFromToml, + toToml: experimentalToToml, +}); + +export type ExperimentalFlagSource = 'master-env' | 'env' | 'config' | 'default'; + +export interface ExperimentalFeatureState { + readonly id: FlagId; + readonly title: string; + readonly description: string; + readonly surface: FlagSurface; + readonly env: string; + readonly defaultEnabled: boolean; + readonly enabled: boolean; + readonly source: ExperimentalFlagSource; + readonly configValue?: boolean; +} + +export interface IFlagService { + readonly _serviceBrand: undefined; + + readonly registry: IFlagRegistry; + enabled(id: FlagId): boolean; + snapshot(): ExperimentalFlagMap; + enabledIds(): readonly FlagId[]; + exposedIds(): readonly FlagId[]; + explain(id: FlagId): ExperimentalFeatureState | undefined; + explainAll(): readonly ExperimentalFeatureState[]; + setConfigOverrides(overrides: ExperimentalFlagConfig | undefined): void; +} + +export const IFlagService: ServiceIdentifier = + createDecorator('flagService'); diff --git a/packages/agent-core-v2/src/app/flag/flagRegistry.ts b/packages/agent-core-v2/src/app/flag/flagRegistry.ts new file mode 100644 index 0000000000000000000000000000000000000000..ba28fd523896bc488d436536853ced485e8af521 --- /dev/null +++ b/packages/agent-core-v2/src/app/flag/flagRegistry.ts @@ -0,0 +1,39 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { IDisposable } from '#/_base/di/lifecycle'; + +import type { IFlagService } from './flag'; + +export type FlagSurface = 'core' | 'tui' | 'both'; + +export type FlagId = string; + +export interface FlagDefinitionInput { + readonly id: FlagId; + readonly title: string; + readonly description: string; + readonly env: string; + readonly default: boolean; + readonly surface: FlagSurface; + readonly isExposed?: (flags: IFlagService) => boolean; +} + +const contributedFlags: FlagDefinitionInput[] = []; + +export function registerFlagDefinition(definition: FlagDefinitionInput): void { + contributedFlags.push(definition); +} + +export function getContributedFlags(): readonly FlagDefinitionInput[] { + return contributedFlags; +} + +export interface IFlagRegistry { + readonly _serviceBrand: undefined; + + register(definition: FlagDefinitionInput): IDisposable; + get(id: FlagId): FlagDefinitionInput | undefined; + list(): readonly FlagDefinitionInput[]; +} + +export const IFlagRegistry: ServiceIdentifier = + createDecorator('flagRegistry'); diff --git a/packages/agent-core-v2/src/app/flag/flagRegistryService.ts b/packages/agent-core-v2/src/app/flag/flagRegistryService.ts new file mode 100644 index 0000000000000000000000000000000000000000..174b295d636f5ef61996c5f6efb2fce9b61bcd89 --- /dev/null +++ b/packages/agent-core-v2/src/app/flag/flagRegistryService.ts @@ -0,0 +1,55 @@ +import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { BugIndicatingError } from '#/errors'; + +import { + type FlagDefinitionInput, + type FlagId, + getContributedFlags, + IFlagRegistry, +} from './flagRegistry'; + +export class FlagRegistryService extends Disposable implements IFlagRegistry { + declare readonly _serviceBrand: undefined; + private readonly byId = new Map(); + + constructor() { + super(); + for (const def of getContributedFlags()) { + this.add(def); + } + } + + register(definition: FlagDefinitionInput): IDisposable { + this.add(definition); + return this._register({ + dispose: () => { + this.byId.delete(definition.id); + }, + }); + } + + get(id: FlagId): FlagDefinitionInput | undefined { + return this.byId.get(id); + } + + list(): readonly FlagDefinitionInput[] { + return [...this.byId.values()]; + } + + private add(definition: FlagDefinitionInput): void { + if (this.byId.has(definition.id)) { + throw new BugIndicatingError(`Flag '${definition.id}' is already registered`); + } + this.byId.set(definition.id, definition); + } +} + +registerScopedService( + LifecycleScope.App, + IFlagRegistry, + FlagRegistryService, + ScopeActivation.OnScopeCreated, + 'flag', +); diff --git a/packages/agent-core-v2/src/app/flag/flagService.ts b/packages/agent-core-v2/src/app/flag/flagService.ts new file mode 100644 index 0000000000000000000000000000000000000000..f89bd858c96e98c9a785cc981a7302e93dba9bda --- /dev/null +++ b/packages/agent-core-v2/src/app/flag/flagService.ts @@ -0,0 +1,121 @@ +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { parseBooleanEnv } from '#/_base/utils/env'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigService } from '#/app/config/config'; + +import { + type ExperimentalFeatureState, + type ExperimentalFlagConfig, + type ExperimentalFlagMap, + type ExperimentalFlagSource, + EXPERIMENTAL_SECTION, + IFlagService, +} from './flag'; +import { type FlagDefinitionInput, type FlagId, IFlagRegistry } from './flagRegistry'; + +export const MASTER_ENV = 'KIMI_CODE_EXPERIMENTAL_FLAG'; + +export class FlagService extends Disposable implements IFlagService { + declare readonly _serviceBrand: undefined; + readonly registry: IFlagRegistry; + private configOverrides: ExperimentalFlagConfig; + + constructor( + @IBootstrapService private readonly bootstrap: IBootstrapService, + @IConfigService private readonly config: IConfigService, + @IFlagRegistry registry: IFlagRegistry, + ) { + super(); + this.registry = registry; + this.configOverrides = this.readConfig(); + this._register( + this.config.onDidChangeConfiguration((e) => { + if (e.domain === EXPERIMENTAL_SECTION) { + this.configOverrides = this.readConfig(); + } + }), + ); + } + + private readConfig(): ExperimentalFlagConfig { + return this.config.get(EXPERIMENTAL_SECTION) ?? {}; + } + + setConfigOverrides(overrides: ExperimentalFlagConfig | undefined): void { + this.configOverrides = overrides ?? {}; + } + + enabled(id: FlagId): boolean { + return this.explain(id)?.enabled ?? false; + } + + explain(id: FlagId): ExperimentalFeatureState | undefined { + const def = this.registry.get(id); + if (def === undefined) return undefined; + const configValue = this.configOverrides[def.id]; + const override = parseBooleanEnv(this.bootstrap.getEnv(def.env)); + if (override !== undefined) return this.state(def, override, 'env', configValue); + if (configValue !== undefined) return this.state(def, configValue, 'config', configValue); + if (parseBooleanEnv(this.bootstrap.getEnv(MASTER_ENV)) === true) { + return this.state(def, true, 'master-env', configValue); + } + return this.state(def, def.default, 'default', undefined); + } + + snapshot(): ExperimentalFlagMap { + return Object.fromEntries( + this.registry.list().map((def) => [def.id, this.enabled(def.id)]), + ); + } + + enabledIds(): readonly FlagId[] { + return this.registry + .list() + .filter((def) => this.enabled(def.id)) + .map((def) => def.id); + } + + exposedIds(): readonly FlagId[] { + return this.registry + .list() + .filter((def) => this.enabled(def.id)) + .filter((def) => def.isExposed?.(this) ?? true) + .map((def) => def.id); + } + + explainAll(): readonly ExperimentalFeatureState[] { + return this.registry + .list() + .map((def) => this.explain(def.id)) + .filter((state): state is ExperimentalFeatureState => state !== undefined); + } + + private state( + def: FlagDefinitionInput, + enabled: boolean, + source: ExperimentalFlagSource, + configValue: boolean | undefined, + ): ExperimentalFeatureState { + return { + id: def.id, + title: def.title, + description: def.description, + surface: def.surface, + env: def.env, + defaultEnabled: def.default, + enabled, + source, + configValue, + }; + } +} + +registerScopedService( + LifecycleScope.App, + IFlagService, + FlagService, + ScopeActivation.OnScopeCreated, + 'flag', +); diff --git a/packages/agent-core-v2/src/app/kosongConfig/builtInModelsDev.ts b/packages/agent-core-v2/src/app/kosongConfig/builtInModelsDev.ts new file mode 100644 index 0000000000000000000000000000000000000000..36b213139f78c3d49a047254a8a1240bf462c326 --- /dev/null +++ b/packages/agent-core-v2/src/app/kosongConfig/builtInModelsDev.ts @@ -0,0 +1,6 @@ +declare const __KIMI_CODE_BUILT_IN_CATALOG__: string | undefined; + +export const BUILT_IN_MODELS_DEV_JSON: string | undefined = + typeof __KIMI_CODE_BUILT_IN_CATALOG__ === 'string' + ? __KIMI_CODE_BUILT_IN_CATALOG__ + : undefined; diff --git a/packages/agent-core-v2/src/app/kosongConfig/configSection.ts b/packages/agent-core-v2/src/app/kosongConfig/configSection.ts new file mode 100644 index 0000000000000000000000000000000000000000..73b3c548589ef41c1d6c5a354c9aad5ae8d874a3 --- /dev/null +++ b/packages/agent-core-v2/src/app/kosongConfig/configSection.ts @@ -0,0 +1,347 @@ +import { z } from 'zod'; + +import { + type ConfigDiagnostic, + type ConfigStripEnv, + envBindings, +} from '#/app/config/config'; +import { registerConfigSection } from '#/app/config/configSectionContributions'; +import { + camelToSnake, + cloneRecord, + isPlainObject, + plainObjectToToml, + setDefined, + snakeToCamel, + transformPlainObject, +} from '#/app/config/toml'; +import { type AssertExact, type Equal } from '#/_base/utils/typeEquality'; +import type { ModelOverride, ModelRecord, ModelsSection } from '#/llm-adapter/model/model'; +import type { ThinkingConfig } from '#/llm-adapter/model/thinking'; +import type { OAuthRef, ProviderConfig, ProvidersSection } from '#/llm-adapter/provider/provider'; +import { ProtocolSchema } from '#/llm-adapter/protocol/protocol'; + +export const PROVIDERS_SECTION = 'providers'; + +export const DEFAULT_PROVIDER_SECTION = 'defaultProvider'; + +export const ENV_MODEL_PROVIDER_KEY = '__kimi_env__'; + +export const ProviderTypeSchema = z.string(); + +export const OAuthRefSchema = z.object({ + storage: z.enum(['file', 'keyring']), + key: z.string().min(1), + oauthHost: z.string().min(1).optional(), +}); + +export const ModelSourceSchema = z.enum(['static', 'discover', 'oauth-catalog']); + +const StringRecordSchema = z.record(z.string(), z.string()); + +export const ProviderConfigSchema = z.object({ + modelSource: ModelSourceSchema.optional(), + + baseUrl: z.string().optional(), + customHeaders: StringRecordSchema.optional(), + defaultModel: z.string().optional(), + + type: ProviderTypeSchema.optional(), + apiKey: z.string().optional(), + oauth: OAuthRefSchema.optional(), + env: StringRecordSchema.optional(), + source: z.record(z.string(), z.unknown()).optional(), +}); + +export const ProvidersSectionSchema = z.record(z.string(), ProviderConfigSchema); + +type _AssertOAuthRef = AssertExact, OAuthRef>>; +type _AssertProviderConfig = AssertExact< + Equal, ProviderConfig> +>; +type _AssertProvidersSection = AssertExact< + Equal, ProvidersSection> +>; + +export const providersEnvBindings = envBindings(ProvidersSectionSchema, { + [ENV_MODEL_PROVIDER_KEY]: envBindings(ProviderConfigSchema, { + apiKey: 'KIMI_MODEL_API_KEY', + type: 'KIMI_MODEL_PROVIDER_TYPE', + baseUrl: 'KIMI_MODEL_BASE_URL', + }), +}); + +export const stripProvidersEnv: ConfigStripEnv> = (value) => { + if (value === undefined || value === null || typeof value !== 'object') return value; + if (!(ENV_MODEL_PROVIDER_KEY in value)) return value; + const out = { ...value }; + delete out[ENV_MODEL_PROVIDER_KEY]; + return out; +}; + +export const providersFromToml = (rawSnake: unknown): unknown => { + if (!isPlainObject(rawSnake)) return rawSnake; + const out: Record = {}; + for (const [name, entry] of Object.entries(rawSnake)) { + out[name] = isPlainObject(entry) ? providerEntryFromToml(entry) : entry; + } + return out; +}; + +function providerEntryFromToml(data: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(data)) { + const targetKey = snakeToCamel(key); + if (targetKey === 'oauth') { + out[targetKey] = isPlainObject(value) ? transformPlainObject(value) : value; + } else if (targetKey === 'env' || targetKey === 'customHeaders') { + out[targetKey] = isPlainObject(value) ? cloneRecord(value) : value; + } else { + out[targetKey] = value; + } + } + return out; +} + +export const providersToToml = (value: unknown, rawSnake: unknown): unknown => { + if (!isPlainObject(value)) return value; + const rawSub = cloneRecord(rawSnake); + const out: Record = {}; + for (const [name, entry] of Object.entries(value)) { + out[name] = isPlainObject(entry) ? providerEntryToToml(entry, rawSub[name]) : entry; + } + return out; +}; + +function providerEntryToToml( + provider: Record, + rawProvider: unknown, +): Record { + const out = cloneRecord(rawProvider); + for (const [key, value] of Object.entries(provider)) { + if (key === 'oauth' && isPlainObject(value)) { + out[camelToSnake(key)] = plainObjectToToml(value, undefined); + } else if ((key === 'env' || key === 'customHeaders') && value !== undefined) { + out[camelToSnake(key)] = cloneRecord(value); + } else { + setDefined(out, camelToSnake(key), value); + } + } + return out; +} + +registerConfigSection(PROVIDERS_SECTION, ProvidersSectionSchema, { + defaultValue: {}, + env: providersEnvBindings, + stripEnv: stripProvidersEnv, + fromToml: providersFromToml, + toToml: providersToToml, +}); + +export const MODELS_SECTION = 'models'; + +export const DEFAULT_MODEL_SECTION = 'defaultModel'; + +const ModelBaseSchema = z.object({ + providerId: z.string().optional(), + + baseUrl: z.string().optional(), + apiKey: z.string().optional(), + oauth: OAuthRefSchema.optional(), + + protocol: ProtocolSchema.optional(), + + name: z.string().optional(), + aliases: z.array(z.string()).optional(), + + provider: z.string().optional(), + model: z.string().optional(), + maxContextSize: z.number().int().min(1).optional(), + maxInputSize: z.number().int().min(1).optional(), + maxOutputSize: z.number().int().min(1).optional(), + capabilities: z.array(z.string()).optional(), + displayName: z.string().optional(), + reasoningKey: z.string().optional(), + adaptiveThinking: z.boolean().optional(), + betaApi: z.boolean().optional(), + supportEfforts: z.array(z.string()).optional(), + defaultEffort: z.string().optional(), + offEffort: z.string().optional(), +}); + +export const ModelOverrideSchema = ModelBaseSchema.omit({ + providerId: true, + baseUrl: true, + apiKey: true, + oauth: true, + protocol: true, + name: true, + aliases: true, + provider: true, + model: true, + betaApi: true, +}).partial(); + +export const ModelRecordSchema = ModelBaseSchema.extend({ + overrides: ModelOverrideSchema.optional(), +}).passthrough(); + +export const ModelsSectionSchema = z.record(z.string(), ModelRecordSchema); + +type _AssertModelOverride = AssertExact< + Equal, ModelOverride> +>; +type _AssertModelRecord = AssertExact, ModelRecord>>; +type _AssertModelsSection = AssertExact< + Equal, ModelsSection> +>; + +const MODEL_OBJECT_FIELDS = new Set( + Object.entries(ModelRecordSchema.shape) + .filter(([, field]) => unwrapWrapperSchema(field as z.ZodTypeAny) instanceof z.ZodObject) + .map(([key]) => camelToSnake(key)), +); + +function unwrapWrapperSchema(schema: z.ZodTypeAny): z.ZodTypeAny { + let current = schema; + while ( + current instanceof z.ZodOptional || + current instanceof z.ZodNullable || + current instanceof z.ZodDefault + ) { + current = current.unwrap() as z.ZodTypeAny; + } + return current; +} + +function collectMalformedModelEntries(rawModels: unknown): ConfigDiagnostic[] { + if (!isPlainObject(rawModels)) return []; + const diagnostics: ConfigDiagnostic[] = []; + for (const [alias, entry] of Object.entries(rawModels)) { + if (!isPlainObject(entry)) continue; + if (entry['model'] !== undefined || entry['name'] !== undefined) continue; + diagnostics.push({ + domain: MODELS_SECTION, + severity: 'warning', + message: malformedModelMessage(alias, entry), + }); + } + return diagnostics; +} + +function malformedModelMessage(alias: string, entry: Record): string { + const base = `[models] entry '${alias}' is missing the 'model' field and cannot be used as a model`; + const dottedAlias = dottedAliasSuffix(alias, entry); + if (dottedAlias === undefined) return `${base}.`; + return `${base}; if the alias contains dots, quote the table name (e.g. [models."${dottedAlias}"]).`; +} + +function dottedAliasSuffix(alias: string, entry: Record): string | undefined { + for (const [key, value] of Object.entries(entry)) { + if (MODEL_OBJECT_FIELDS.has(key) || !isPlainObject(value)) continue; + return dottedAliasSuffix(`${alias}.${key}`, value) ?? `${alias}.${key}`; + } + return undefined; +} + +export const modelsFromToml = (rawSnake: unknown): unknown => { + if (!isPlainObject(rawSnake)) return rawSnake; + const out: Record = {}; + for (const [id, entry] of Object.entries(rawSnake)) { + if (!isPlainObject(entry)) { + out[id] = entry; + continue; + } + const converted = transformPlainObject(entry); + if (isPlainObject(converted['overrides'])) { + converted['overrides'] = transformPlainObject(converted['overrides']); + } + out[id] = converted; + } + return out; +}; + +export const modelsToToml = (value: unknown, rawSnake: unknown): unknown => { + if (!isPlainObject(value)) return value; + const rawSub = cloneRecord(rawSnake); + const out: Record = {}; + for (const [id, entry] of Object.entries(value)) { + if (!isPlainObject(entry)) { + out[id] = entry; + continue; + } + const merged = cloneRecord(rawSub[id]); + for (const [key, field] of Object.entries(entry)) { + if (key === 'capabilities' && Array.isArray(field)) { + merged[camelToSnake(key)] = [...field]; + } else if (key === 'overrides' && isPlainObject(field)) { + merged['overrides'] = modelOverridesToToml(field, merged['overrides']); + } else { + setDefined(merged, camelToSnake(key), field); + } + } + out[id] = merged; + } + return out; +}; + +function modelOverridesToToml( + overrides: Record, + rawSnake: unknown, +): Record { + const out = cloneRecord(rawSnake); + for (const [key, value] of Object.entries(overrides)) { + if (key === 'capabilities' && Array.isArray(value)) { + out[camelToSnake(key)] = [...value]; + } else { + setDefined(out, camelToSnake(key), value); + } + } + return out; +} + +registerConfigSection(MODELS_SECTION, ModelsSectionSchema, { + defaultValue: {}, + fromToml: modelsFromToml, + toToml: modelsToToml, + collectDiagnostics: collectMalformedModelEntries, +}); + +export const THINKING_SECTION = 'thinking'; + +export const ThinkingConfigSchema = z.object({ + enabled: z.boolean().optional(), + effort: z.string().optional(), + forcedEffort: z.string().optional(), + keep: z.string().optional(), +}); + +type _AssertThinkingConfig = AssertExact< + Equal, ThinkingConfig> +>; + +export const thinkingEnvBindings = envBindings(ThinkingConfigSchema, { + forcedEffort: 'KIMI_MODEL_THINKING_EFFORT', +}); + +export const stripThinkingEnv: ConfigStripEnv = (value) => { + const result = { ...value }; + delete result.forcedEffort; + return result; +}; + +registerConfigSection(THINKING_SECTION, ThinkingConfigSchema, { + env: thinkingEnvBindings, + stripEnv: stripThinkingEnv, +}); + +export const MODEL_CATALOG_SECTION = 'modelCatalog'; + +export const ModelCatalogConfigSchema = z.object({ + refreshIntervalMs: z.number().int().min(0).optional(), + refreshOnStart: z.boolean().optional(), +}); + +export type ModelCatalogConfig = z.infer; + +registerConfigSection(MODEL_CATALOG_SECTION, ModelCatalogConfigSchema); diff --git a/packages/agent-core-v2/src/app/kosongConfig/discovery.ts b/packages/agent-core-v2/src/app/kosongConfig/discovery.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb75eed388e43782389783fd653b46bfb1e2aec5 --- /dev/null +++ b/packages/agent-core-v2/src/app/kosongConfig/discovery.ts @@ -0,0 +1,62 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { z } from 'zod'; + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { Event2 } from '#/app/event/event2'; + +export const providerRefreshChangeSchema = z.object({ + provider_id: z.string().min(1), + provider_name: z.string().min(1), + added: z.number().int().min(0), + removed: z.number().int().min(0), +}); +export type ProviderRefreshChange = z.infer; + +export const providerRefreshFailureSchema = z.object({ + provider: z.string().min(1), + reason: z.string().min(1), +}); +export type ProviderRefreshFailure = z.infer; + +export const refreshProviderModelsResponseSchema = z.object({ + changed: z.array(providerRefreshChangeSchema), + unchanged: z.array(z.string().min(1)), + failed: z.array(providerRefreshFailureSchema), +}); +export type RefreshProviderModelsResponse = z.infer< + typeof refreshProviderModelsResponseSchema +>; + +export type RefreshProviderModelsScope = 'all' | 'oauth'; + +export class ModelCatalogChanged extends Event2<{ + readonly payload: RefreshProviderModelsResponse; +}> { + static override readonly type = 'event.model_catalog.changed'; +} +export interface ModelCatalogChanged { + readonly payload: RefreshProviderModelsResponse; +} + +export interface ModelCatalogChangedEvent { + readonly type: 'event.model_catalog.changed'; + readonly changed: readonly ProviderRefreshChange[]; + readonly unchanged: readonly string[]; + readonly failed: readonly ProviderRefreshFailure[]; +} + +export interface RefreshProviderModelsOptions { + readonly scope?: RefreshProviderModelsScope; + readonly providerId?: string; +} + +export interface IProviderDiscoveryService { + readonly _serviceBrand: undefined; + + refreshProviderModels( + options?: RefreshProviderModelsOptions, + ): Promise; +} + +export const IProviderDiscoveryService: ServiceIdentifier = + createDecorator('providerDiscovery'); diff --git a/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts b/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts new file mode 100644 index 0000000000000000000000000000000000000000..b7ad06730993006b26da35fd833c56003b1f919c --- /dev/null +++ b/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts @@ -0,0 +1,295 @@ +import { + refreshProviderModels, + type ManagedKimiConfigShape, + type ManagedKimiOAuthRef, + type RefreshProviderHost, + type RefreshResult, +} from '@moonshot-ai/kimi-code-oauth'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Error2 } from '#/_base/errors/errors'; +import { IOAuthService } from '#/app/auth/auth'; +import { AuthErrors } from '#/app/auth/errors'; +import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; +import { IConfigService } from '#/app/config/config'; +import { IEventService } from '#/app/event/event'; +import { ModelCatalogErrors } from '#/llm-adapter/model/errors'; +import { type ModelRecord } from '#/llm-adapter/model/model'; +import { + IProviderService, + type ModelSource, + type OAuthRef, + type ProviderConfig, +} from '#/llm-adapter/provider/provider'; +import { getProviderDefinition } from '#/llm-adapter/provider/provider-definition'; + +import { + DEFAULT_MODEL_SECTION, + MODELS_SECTION, + PROVIDERS_SECTION, + THINKING_SECTION, +} from './configSection'; +import { + IProviderDiscoveryService, + ModelCatalogChanged, + type RefreshProviderModelsOptions, + type RefreshProviderModelsResponse, +} from './discovery'; + +interface StaticExclusion { + readonly providers: Readonly>; + readonly models: Readonly>; + readonly defaultModel?: string; + readonly thinking?: ManagedKimiConfigShape['thinking']; +} + +const EMPTY_EXCLUSION: StaticExclusion = { providers: {}, models: {} }; + +export class ProviderDiscoveryService implements IProviderDiscoveryService { + declare readonly _serviceBrand: undefined; + + private refreshChain: Promise = Promise.resolve(); + + constructor( + @IProviderService private readonly providerService: IProviderService, + @IConfigService private readonly config: IConfigService, + @IOAuthService private readonly oauth: IOAuthService, + @IEventService private readonly events: IEventService, + @IAgentIdentity private readonly identity: IAgentIdentity, + ) {} + + refreshProviderModels( + options: RefreshProviderModelsOptions = {}, + ): Promise { + const run = this.refreshChain.then(() => this.doRefreshProviderModels(options)); + this.refreshChain = run.then( + () => undefined, + () => undefined, + ); + return run; + } + + private async doRefreshProviderModels( + options: RefreshProviderModelsOptions, + ): Promise { + await this.config.reload(); + if (options.providerId !== undefined) { + const provider = this.providerService.get(options.providerId); + if (provider === undefined) { + throw new Error2( + ModelCatalogErrors.codes.PROVIDER_NOT_FOUND, + `provider ${options.providerId} does not exist`, + ); + } + if (this.effectiveModelSource(provider) === 'static') { + return { changed: [], unchanged: [options.providerId], failed: [] }; + } + } + + const exclusion = this.computeStaticExclusion(); + const { outboundUserAgent } = await this.identity.resolved(); + const result = await refreshProviderModels(this.buildRefreshHost(exclusion, outboundUserAgent), { + scope: options.scope, + providerId: options.providerId, + }); + const response = mapRefreshResult(result); + if (response.changed.length > 0) { + this.events.publish(new ModelCatalogChanged({ payload: response })); + } + return response; + } + + private effectiveModelSource(provider: ProviderConfig): ModelSource | undefined { + return ( + provider.modelSource ?? + (provider.type === undefined ? undefined : getProviderDefinition(provider.type)?.modelSource) + ); + } + + private computeStaticExclusion(): StaticExclusion { + const providers = + this.config.inspect>(PROVIDERS_SECTION).userValue ?? {}; + const staticIds = Object.entries(providers) + .filter(([, provider]) => this.effectiveModelSource(provider) === 'static') + .map(([id]) => id); + if (staticIds.length === 0) return EMPTY_EXCLUSION; + + const excludedProviders: Record = {}; + for (const id of staticIds) { + const provider = providers[id]; + if (provider !== undefined) excludedProviders[id] = provider; + } + const models = + this.config.inspect>(MODELS_SECTION).userValue ?? {}; + const excludedModels: Record = {}; + for (const [modelId, record] of Object.entries(models)) { + if (record.provider !== undefined && record.provider in excludedProviders) { + excludedModels[modelId] = record; + } + } + const defaultModel = this.config.inspect(DEFAULT_MODEL_SECTION).userValue; + const thinking = this.config.inspect( + THINKING_SECTION, + ).userValue; + return { + providers: excludedProviders, + models: excludedModels, + defaultModel: + defaultModel !== undefined && defaultModel in excludedModels ? defaultModel : undefined, + thinking: + defaultModel !== undefined && defaultModel in excludedModels ? thinking : undefined, + }; + } + + private buildRefreshHost(exclusion: StaticExclusion, userAgent: string): RefreshProviderHost { + return { + getConfig: async () => this.readUserConfigShape(exclusion), + removeProvider: (providerId) => this.shapeWithoutProvider(providerId), + setConfig: (patch) => this.applyRefreshPatch(patch, exclusion), + resolveOAuthToken: (providerName, oauthRef) => this.resolveOAuthToken(providerName, oauthRef), + userAgent, + }; + } + + private readUserConfigShape(exclusion: StaticExclusion = EMPTY_EXCLUSION): ManagedKimiConfigShape { + const providers = + this.config.inspect>(PROVIDERS_SECTION).userValue ?? {}; + const models = + this.config.inspect>(MODELS_SECTION).userValue ?? {}; + const defaultModel = this.config.inspect(DEFAULT_MODEL_SECTION).userValue; + const thinking = + this.config.inspect(THINKING_SECTION).userValue; + const visibleModels = withoutKeys(models, exclusion.models); + const excludedDefaultModel = exclusion.defaultModel; + const excludedDefaultRecord = + excludedDefaultModel !== undefined ? models[excludedDefaultModel] : undefined; + return { + providers: withoutKeys(providers, exclusion.providers) as ManagedKimiConfigShape['providers'], + models: (excludedDefaultModel !== undefined && excludedDefaultRecord !== undefined + ? { ...visibleModels, [excludedDefaultModel]: excludedDefaultRecord } + : visibleModels) as ManagedKimiConfigShape['models'], + defaultModel, + thinking: thinking === undefined ? undefined : { ...thinking }, + }; + } + + private shapeWithoutProvider(providerId: string): Promise { + const current = this.readUserConfigShape(); + const providers = current.providers as Record; + const restProviders = Object.fromEntries( + Object.entries(providers).filter(([id]) => id !== providerId), + ); + const models = (current.models ?? {}) as Record; + const restModels = Object.fromEntries( + Object.entries(models).filter(([, record]) => record.provider !== providerId), + ); + return Promise.resolve({ + ...current, + providers: restProviders, + models: restModels, + } as ManagedKimiConfigShape); + } + + private async applyRefreshPatch( + patch: ManagedKimiConfigShape, + exclusion: StaticExclusion, + ): Promise { + const userProviders = + this.config.inspect>(PROVIDERS_SECTION).userValue ?? {}; + const userModels = + this.config.inspect>(MODELS_SECTION).userValue ?? {}; + const sections: Record = {}; + if (patch.providers !== undefined) { + sections[PROVIDERS_SECTION] = { + ...exclusion.providers, + ...patch.providers, + }; + } + if (patch.models !== undefined) { + sections[MODELS_SECTION] = { + ...exclusion.models, + ...(patch.models as Record), + }; + } + const restoreDefault = exclusion.defaultModel !== undefined; + if ('defaultModel' in patch) { + sections[DEFAULT_MODEL_SECTION] = restoreDefault + ? exclusion.defaultModel + : patch.defaultModel; + } + if ('thinking' in patch) { + sections[THINKING_SECTION] = restoreDefault ? exclusion.thinking : patch.thinking; + } + await this.config.replaceSections(sections); + return { + providers: + patch.providers !== undefined + ? ({ ...exclusion.providers, ...patch.providers } as ManagedKimiConfigShape['providers']) + : (userProviders as ManagedKimiConfigShape['providers']), + models: + patch.models !== undefined + ? ({ ...exclusion.models, ...patch.models } as ManagedKimiConfigShape['models']) + : (userModels as ManagedKimiConfigShape['models']), + defaultModel: + 'defaultModel' in patch + ? restoreDefault + ? exclusion.defaultModel + : patch.defaultModel + : this.config.inspect(DEFAULT_MODEL_SECTION).userValue, + thinking: + 'thinking' in patch + ? restoreDefault + ? exclusion.thinking + : patch.thinking + : this.config.inspect(THINKING_SECTION).userValue, + }; + } + + private async resolveOAuthToken( + providerName: string, + oauthRef?: ManagedKimiOAuthRef, + ): Promise { + const tokenProvider = this.oauth.resolveTokenProvider( + providerName, + oauthRef as unknown as OAuthRef | undefined, + ); + if (tokenProvider === undefined) { + throw new Error2(AuthErrors.codes.AUTH_TOKEN_MISSING, 'OAuth token provider is not configured.', { + details: { provider_id: providerName }, + }); + } + return tokenProvider.getAccessToken(); + } +} + +function withoutKeys( + record: Readonly>, + excluded: Readonly>, +): Record { + if (Object.keys(excluded).length === 0) return { ...record }; + return Object.fromEntries(Object.entries(record).filter(([key]) => !(key in excluded))); +} + +function mapRefreshResult(result: RefreshResult): RefreshProviderModelsResponse { + return { + changed: result.changed.map((change) => ({ + provider_id: change.providerId, + provider_name: change.providerName, + added: change.added, + removed: change.removed, + })), + unchanged: [...result.unchanged], + failed: result.failed.map((failure) => ({ + provider: failure.provider, + reason: failure.reason, + })), + }; +} + +registerScopedService( + LifecycleScope.App, + IProviderDiscoveryService, + ProviderDiscoveryService, + ScopeActivation.OnScopeCreated, + 'kosongConfig', +); diff --git a/packages/agent-core-v2/src/app/kosongConfig/envOverlay.ts b/packages/agent-core-v2/src/app/kosongConfig/envOverlay.ts new file mode 100644 index 0000000000000000000000000000000000000000..d0c4e6311773fafc875a136ae1fef6a3d1d68410 --- /dev/null +++ b/packages/agent-core-v2/src/app/kosongConfig/envOverlay.ts @@ -0,0 +1,229 @@ +import { parseBooleanEnv } from '#/_base/utils/env'; +import { Error2 } from '#/_base/errors/errors'; + +import type { ConfigEffectiveOverlay } from '#/app/config/config'; +import { registerConfigOverlay } from '#/app/config/configOverlayContributions'; +import { CONFIG_INVALID_ERROR_CODE } from '#/llm-adapter/contract/errors'; +import { resolveProviderEndpoint } from '#/llm-adapter/provider/provider-definition'; + +import { ENV_MODEL_PROVIDER_KEY } from './configSection'; + +export const ENV_MODEL_ALIAS_KEY = '__kimi_env_model__'; + +const DEFAULT_MAX_CONTEXT_SIZE = 262144; + +const DEFAULT_CAPABILITIES = ['image_in', 'thinking']; + +function trimmed(value: string | undefined): string | undefined { + const t = value?.trim(); + return t === undefined || t.length === 0 ? undefined : t; +} + +function fail(message: string): never { + throw new Error2(CONFIG_INVALID_ERROR_CODE, message); +} + +function parsePositiveInt(raw: string, varName: string): number { + if (!/^\d+$/.test(raw) || Number(raw) <= 0) { + fail(`${varName} must be a positive integer, got "${raw}".`); + } + return Number(raw); +} + +function parseFloatEnv(raw: string | undefined, varName: string): number | undefined { + const value = trimmed(raw); + if (value === undefined) return undefined; + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + fail(`${varName} must be a number, got "${raw}".`); + } + return parsed; +} + +function parseCompletionTokens(raw: string | undefined): number | undefined { + const value = trimmed(raw); + if (value === undefined) return undefined; + const parsed = Number(value); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) return undefined; + return parsed; +} + +function parseCapabilities(raw: string | undefined): string[] | undefined { + if (raw === undefined) return undefined; + const caps = raw + .split(',') + .map((c) => c.trim().toLowerCase()) + .filter((c) => c.length > 0); + return caps.length === 0 ? undefined : caps; +} + +function parseBooleanVar(raw: string | undefined, varName: string): boolean | undefined { + const value = trimmed(raw); + if (value === undefined) return undefined; + const parsed = parseBooleanEnv(value); + if (parsed === undefined) { + fail(`${varName} must be a boolean (true/false/1/0/yes/no/on/off), got "${raw}".`); + } + return parsed; +} + +function asRecord(value: unknown): Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function withoutKey(value: unknown, key: string): unknown { + if ( + !(typeof value === 'object' && value !== null && !Array.isArray(value) && key in value) + ) { + return value; + } + const out: Record = { ...(value as Record) }; + delete out[key]; + return out; +} + +export const kimiModelEnvOverlay: ConfigEffectiveOverlay = { + apply(effective, getEnv, validate) { + const model = trimmed(getEnv('KIMI_MODEL_NAME')); + const temperature = parseFloatEnv( + getEnv('KIMI_MODEL_TEMPERATURE'), + 'KIMI_MODEL_TEMPERATURE', + ); + const topP = parseFloatEnv(getEnv('KIMI_MODEL_TOP_P'), 'KIMI_MODEL_TOP_P'); + const thinkingKeep = trimmed(getEnv('KIMI_MODEL_THINKING_KEEP')); + const maxCompletionTokens = + parseCompletionTokens(getEnv('KIMI_MODEL_MAX_COMPLETION_TOKENS')) ?? + parseCompletionTokens(getEnv('KIMI_MODEL_MAX_TOKENS')); + + const changed: string[] = []; + + if (model === undefined) { + const modelOverrides = collectModelOverrides({ + temperature, + topP, + thinkingKeep, + maxCompletionTokens, + }); + if (modelOverrides !== undefined) { + effective['modelOverrides'] = modelOverrides; + changed.push('modelOverrides'); + } + return changed; + } + + const maxContextRaw = trimmed(getEnv('KIMI_MODEL_MAX_CONTEXT_SIZE')); + const maxContextSize = + maxContextRaw === undefined + ? DEFAULT_MAX_CONTEXT_SIZE + : parsePositiveInt(maxContextRaw, 'KIMI_MODEL_MAX_CONTEXT_SIZE'); + + const maxOutputRaw = trimmed(getEnv('KIMI_MODEL_MAX_OUTPUT_SIZE')); + const maxOutputSize = + maxOutputRaw === undefined + ? undefined + : parsePositiveInt(maxOutputRaw, 'KIMI_MODEL_MAX_OUTPUT_SIZE'); + const capabilities = parseCapabilities(getEnv('KIMI_MODEL_CAPABILITIES')) ?? DEFAULT_CAPABILITIES; + const displayName = trimmed(getEnv('KIMI_MODEL_DISPLAY_NAME')); + const reasoningKey = trimmed(getEnv('KIMI_MODEL_REASONING_KEY')); + const adaptiveThinking = parseBooleanVar( + getEnv('KIMI_MODEL_ADAPTIVE_THINKING'), + 'KIMI_MODEL_ADAPTIVE_THINKING', + ); + + const alias: Record = { + provider: ENV_MODEL_PROVIDER_KEY, + model, + maxContextSize, + capabilities, + }; + if (displayName !== undefined) alias['displayName'] = displayName; + if (maxOutputSize !== undefined) alias['maxOutputSize'] = maxOutputSize; + if (reasoningKey !== undefined) alias['reasoningKey'] = reasoningKey; + if (adaptiveThinking !== undefined) alias['adaptiveThinking'] = adaptiveThinking; + + const models = asRecord(effective['models']); + const nextModels = { ...models, [ENV_MODEL_ALIAS_KEY]: alias }; + effective['models'] = validate('models', nextModels); + changed.push('models'); + + const providers = asRecord(effective['providers']); + const envProvider = asRecord(providers[ENV_MODEL_PROVIDER_KEY]); + const providerType = + typeof envProvider['type'] === 'string' ? envProvider['type'] : 'kimi'; + const providerBaseUrl = + typeof envProvider['baseUrl'] === 'string' && envProvider['baseUrl'].length > 0 + ? envProvider['baseUrl'] + : + resolveProviderEndpoint(providerType, envBagOf(getEnv)).baseUrl; + const providerPatch: Record = {}; + if (envProvider['type'] === undefined) providerPatch['type'] = 'kimi'; + if (providerBaseUrl !== undefined && envProvider['baseUrl'] === undefined) { + providerPatch['baseUrl'] = providerBaseUrl; + } + if (Object.keys(providerPatch).length > 0) { + effective['providers'] = validate('providers', { + ...providers, + [ENV_MODEL_PROVIDER_KEY]: { ...envProvider, ...providerPatch }, + }); + changed.push('providers'); + } + + effective['defaultModel'] = ENV_MODEL_ALIAS_KEY; + changed.push('defaultModel'); + + const modelOverrides = collectModelOverrides({ + temperature, + topP, + thinkingKeep, + maxCompletionTokens, + }); + if (modelOverrides !== undefined) { + effective['modelOverrides'] = modelOverrides; + changed.push('modelOverrides'); + } + + return changed; + }, + + strip(domain, value, rawSnake) { + switch (domain) { + case 'models': + return withoutKey(value, ENV_MODEL_ALIAS_KEY); + case 'defaultModel': + if (value !== ENV_MODEL_ALIAS_KEY) return value; + return typeof rawSnake['default_model'] === 'string' ? rawSnake['default_model'] : undefined; + case 'modelOverrides': + return undefined; + default: + return value; + } + }, +}; + +function envBagOf( + getEnv: (name: string) => string | undefined, +): Readonly> { + return new Proxy>({}, { + get: (_target, key: string) => getEnv(key), + }); +} + +function collectModelOverrides(input: { + readonly temperature: number | undefined; + readonly topP: number | undefined; + readonly thinkingKeep: string | undefined; + readonly maxCompletionTokens: number | undefined; +}): Record | undefined { + const modelOverrides: Record = {}; + if (input.temperature !== undefined) modelOverrides['temperature'] = input.temperature; + if (input.topP !== undefined) modelOverrides['topP'] = input.topP; + if (input.thinkingKeep !== undefined) modelOverrides['thinkingKeep'] = input.thinkingKeep; + if (input.maxCompletionTokens !== undefined) { + modelOverrides['maxCompletionTokens'] = input.maxCompletionTokens; + } + return Object.keys(modelOverrides).length > 0 ? modelOverrides : undefined; +} + +registerConfigOverlay(kimiModelEnvOverlay); diff --git a/packages/agent-core-v2/src/app/kosongConfig/errors.ts b/packages/agent-core-v2/src/app/kosongConfig/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..19c8ea6ff8f0ac7a58ab53e78761a282d3bd3e3e --- /dev/null +++ b/packages/agent-core-v2/src/app/kosongConfig/errors.ts @@ -0,0 +1,45 @@ +import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; + +export const ModelsDevImportErrors = { + codes: { + CATALOG_UNAVAILABLE: 'modelsDev.catalog_unavailable', + CATALOG_ENTRY_NOT_FOUND: 'modelsDev.catalog_entry_not_found', + CATALOG_IMPORT_INVALID: 'modelsDev.import_invalid', + REGISTRY_IMPORT_INVALID: 'modelsDev.registry_import_invalid', + PROVIDER_OAUTH_MANAGED: 'provider.oauth_managed', + }, + info: { + 'modelsDev.catalog_unavailable': { + title: 'models.dev directory unavailable', + retryable: true, + public: true, + action: 'Check the network connection to models.dev and try again.', + }, + 'modelsDev.catalog_entry_not_found': { + title: 'Directory entry not found', + retryable: false, + public: true, + action: 'Check the catalog id against the models.dev directory listing.', + }, + 'modelsDev.import_invalid': { + title: 'Directory entry not importable', + retryable: false, + public: true, + action: 'Pick another entry or supply the required base_url.', + }, + 'modelsDev.registry_import_invalid': { + title: 'Custom registry not importable', + retryable: false, + public: true, + action: 'Check the registry URL and credentials.', + }, + 'provider.oauth_managed': { + title: 'Provider managed by OAuth login', + retryable: false, + public: true, + action: 'Log out via the OAuth flow instead of editing the provider.', + }, + }, +} as const satisfies ErrorDomain; + +registerErrorDomain(ModelsDevImportErrors); diff --git a/packages/agent-core-v2/src/app/kosongConfig/hostRequestHeadersAdapter.ts b/packages/agent-core-v2/src/app/kosongConfig/hostRequestHeadersAdapter.ts new file mode 100644 index 0000000000000000000000000000000000000000..4525e708f4477cf120b289b84f4b3180cd6ffdab --- /dev/null +++ b/packages/agent-core-v2/src/app/kosongConfig/hostRequestHeadersAdapter.ts @@ -0,0 +1,33 @@ +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IHostRequestHeaders } from '#/llm-adapter/model/host-request-headers'; + +export class HostRequestHeadersAdapter implements IHostRequestHeaders { + readonly headers: Readonly>; + + constructor( + @IBootstrapService bootstrap: IBootstrapService, + @IAgentIdentity private readonly identity: IAgentIdentity, + ) { + this.headers = bootstrap.args.requestHeaders; + } + + get thirdPartyHeaders(): Readonly> { + const userAgent = this.identity.current().thirdPartyUserAgent; + return userAgent === undefined ? {} : { 'User-Agent': userAgent }; + } + + get identitySlug(): string | undefined { + return this.identity.current().slug; + } +} + +registerScopedService( + LifecycleScope.App, + IHostRequestHeaders, + HostRequestHeadersAdapter, + ScopeActivation.OnDemand, + 'kosongConfig', +); diff --git a/packages/agent-core-v2/src/app/kosongConfig/kosongConfig.ts b/packages/agent-core-v2/src/app/kosongConfig/kosongConfig.ts new file mode 100644 index 0000000000000000000000000000000000000000..1326cf4ffcc3da446d24ef45a2d0a9cc0f6d1a27 --- /dev/null +++ b/packages/agent-core-v2/src/app/kosongConfig/kosongConfig.ts @@ -0,0 +1,10 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IKosongConfigService { + readonly _serviceBrand: undefined; + + readonly ready: Promise; +} + +export const IKosongConfigService: ServiceIdentifier = + createDecorator('kosongConfigService'); diff --git a/packages/agent-core-v2/src/app/kosongConfig/kosongConfigService.ts b/packages/agent-core-v2/src/app/kosongConfig/kosongConfigService.ts new file mode 100644 index 0000000000000000000000000000000000000000..e2fc9726184628a7c125991c872752ce9f087794 --- /dev/null +++ b/packages/agent-core-v2/src/app/kosongConfig/kosongConfigService.ts @@ -0,0 +1,179 @@ +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { retryBackoffDelays, sleepForRetry } from '#/_base/utils/retry'; + +import { type ConfigSectionChangedEvent, IConfigService } from '#/app/config/config'; +import { describeUnknownError } from '#/app/config/configPure'; +import { deepEqual } from '#/app/config/sectionDiff'; +import { IModelService, type ModelsSection } from '#/llm-adapter/model/model'; +import { IProviderService, type ProvidersSection } from '#/llm-adapter/provider/provider'; + +import { IKosongConfigService } from './kosongConfig'; +import { + DEFAULT_MODEL_SECTION, + DEFAULT_PROVIDER_SECTION, + MODELS_SECTION, + PROVIDERS_SECTION, +} from './configSection'; + +const PERSIST_MAX_ATTEMPTS = 3; + +export class KosongConfigService extends Disposable implements IKosongConfigService { + declare readonly _serviceBrand: undefined; + + readonly ready: Promise; + + private persistChain: Promise = Promise.resolve(); + + constructor( + @IConfigService private readonly config: IConfigService, + @IProviderService private readonly providers: IProviderService, + @IModelService private readonly models: IModelService, + @ILogService private readonly log: ILogService, + ) { + super(); + this.ready = this.initialize(); + void this.ready.catch((error) => { + this.log.warn('kosong config bridge initialization failed', { + error: describeUnknownError(error), + }); + }); + } + + private async initialize(): Promise { + await this.config.ready; + this.providers.loadAll( + this.config.get(PROVIDERS_SECTION) ?? {}, + this.config.get(DEFAULT_PROVIDER_SECTION), + ); + this.models.loadAll( + this.config.get(MODELS_SECTION) ?? {}, + this.config.get(DEFAULT_MODEL_SECTION), + ); + this._register(this.config.onDidSectionChange((e) => this.onConfigSectionChanged(e))); + this._register( + this.providers.onDidChangeProviders((e) => { + if ( + deepEqual(this.config.get(PROVIDERS_SECTION) ?? {}, this.providers.list()) + ) { + return; + } + e.waitUntil(this.enqueuePersistProviders()); + }), + ); + this._register( + this.providers.onDidChangeDefaultProvider((e) => { + if (this.config.get(DEFAULT_PROVIDER_SECTION) === e.id) return; + e.waitUntil(this.enqueuePersistDefaultPointer(DEFAULT_PROVIDER_SECTION, e.id)); + }), + ); + this._register( + this.models.onDidChangeModels((e) => { + if (deepEqual(this.config.get(MODELS_SECTION) ?? {}, this.models.list())) { + return; + } + e.waitUntil(this.enqueuePersistModels()); + }), + ); + this._register( + this.models.onDidChangeDefaultModel((e) => { + if (this.config.get(DEFAULT_MODEL_SECTION) === e.id) return; + e.waitUntil(this.enqueuePersistDefaultPointer(DEFAULT_MODEL_SECTION, e.id)); + }), + ); + } + + private onConfigSectionChanged(e: ConfigSectionChangedEvent): void { + switch (e.domain) { + case PROVIDERS_SECTION: + this.providers.loadAll( + (e.value as ProvidersSection | undefined) ?? {}, + this.providers.getDefaultProvider(), + ); + break; + case MODELS_SECTION: + this.models.loadAll( + (e.value as ModelsSection | undefined) ?? {}, + this.models.getDefaultModel(), + ); + break; + case DEFAULT_PROVIDER_SECTION: + void this.providers + .setDefaultProvider(e.value as string | undefined) + .catch((error) => this.logPersistFailure(error)); + break; + case DEFAULT_MODEL_SECTION: + void this.models + .setDefaultModel(e.value as string | undefined) + .catch((error) => this.logPersistFailure(error)); + break; + } + } + + private enqueuePersistProviders(): Promise { + return this.enqueue(async () => { + const next = this.providers.list(); + if (deepEqual(this.config.get(PROVIDERS_SECTION) ?? {}, next)) return; + await this.replaceWithRetry(PROVIDERS_SECTION, next); + }); + } + + private enqueuePersistModels(): Promise { + return this.enqueue(async () => { + const next = this.models.list(); + if (deepEqual(this.config.get(MODELS_SECTION) ?? {}, next)) return; + await this.replaceWithRetry(MODELS_SECTION, next); + }); + } + + private enqueuePersistDefaultPointer(domain: string, value: string | undefined): Promise { + return this.enqueue(async () => { + if (this.config.get(domain) === value) return; + await this.replaceWithRetry(domain, value); + const effective = this.config.get(domain); + if (effective === value) return; + if (domain === DEFAULT_PROVIDER_SECTION) { + void this.providers + .setDefaultProvider(effective) + .catch((error) => this.logPersistFailure(error)); + } else if (domain === DEFAULT_MODEL_SECTION) { + void this.models + .setDefaultModel(effective) + .catch((error) => this.logPersistFailure(error)); + } + }); + } + + private async replaceWithRetry(domain: string, value: unknown): Promise { + const delays = retryBackoffDelays(PERSIST_MAX_ATTEMPTS); + for (let attempt = 0; ; attempt += 1) { + try { + await this.config.replace(domain, value); + return; + } catch (error) { + const delay = delays[attempt]; + if (delay === undefined) throw error; + await sleepForRetry(delay); + } + } + } + + private enqueue(task: () => Promise): Promise { + this.persistChain = this.persistChain.then(task).catch((error) => this.logPersistFailure(error)); + return this.persistChain; + } + + private logPersistFailure(error: unknown): void { + this.log.warn('kosong config persist failed', { error: describeUnknownError(error) }); + } +} + +registerScopedService( + LifecycleScope.App, + IKosongConfigService, + KosongConfigService, + ScopeActivation.OnScopeCreated, + 'kosongConfig', +); diff --git a/packages/agent-core-v2/src/app/kosongConfig/modelsDev.ts b/packages/agent-core-v2/src/app/kosongConfig/modelsDev.ts new file mode 100644 index 0000000000000000000000000000000000000000..f2097a344241ace070969a5fbb60492fbcb2291c --- /dev/null +++ b/packages/agent-core-v2/src/app/kosongConfig/modelsDev.ts @@ -0,0 +1,323 @@ +import type { ModelCapability } from '#/llm-adapter/contract/capability'; +import type { ProviderType } from '#/llm-adapter/provider/provider'; + +import { wireHasProtocolThinkingDisable } from '#/llm-adapter/model/thinking'; + +export interface ModelsDevModelEntry { + readonly id?: string; + readonly name?: string; + readonly family?: string; + readonly limit?: { readonly context?: number; readonly input?: number; readonly output?: number }; + readonly tool_call?: boolean; + readonly reasoning?: boolean; + readonly reasoning_options?: readonly ModelsDevReasoningOption[]; + readonly status?: string; + readonly provider?: ModelsDevModelProviderOverride; + readonly dynamically_loaded_tools?: boolean; + readonly interleaved?: boolean | { readonly field?: string }; + readonly modalities?: { + readonly input?: readonly string[]; + readonly output?: readonly string[]; + }; +} + +export interface ModelsDevReasoningOption { + readonly type?: string; + readonly values?: unknown; +} + +export interface ModelsDevModelProviderOverride { + readonly npm?: string; + readonly api?: string; +} + +export interface ModelsDevProviderEntry { + readonly id?: string; + readonly name?: string; + readonly api?: string; + readonly env?: readonly string[]; + readonly npm?: string; + readonly type?: string; + readonly models?: Record; +} + +export type ModelsDevCatalog = Record; + +export interface ModelsDevModel { + readonly id: string; + readonly name?: string; + readonly maxOutputSize?: number; + readonly reasoningKey?: string; + readonly supportEfforts?: readonly string[]; + readonly offEffort?: string; + readonly alwaysThinking?: boolean; + readonly protocol?: 'anthropic'; + readonly baseUrl?: string; + readonly capability: ModelCapability; +} + +const KNOWN_WIRE_TYPES = [ + 'anthropic', + 'openai', + 'kimi', + 'google-genai', + 'openai_responses', + 'vertexai', +] as const satisfies readonly ProviderType[]; + +type KnownWireType = (typeof KNOWN_WIRE_TYPES)[number]; + +function isWireType(value: unknown): value is KnownWireType { + return typeof value === 'string' && (KNOWN_WIRE_TYPES as readonly string[]).includes(value); +} + +function hasEmbeddingMarker(value: string | undefined): boolean { + if (value === undefined) return false; + const lower = value.toLowerCase(); + return lower.includes('embedding') || /(?:^|[-_/])embed(?:$|[-_/])/.test(lower); +} + +function isUsableChatModel(model: ModelsDevModelEntry): boolean { + const outputModalities = model.modalities?.output; + if (outputModalities !== undefined && !outputModalities.includes('text')) return false; + if (model.status === 'deprecated' || model.status === 'alpha') return false; + return ( + !hasEmbeddingMarker(model.family) && + !hasEmbeddingMarker(model.id) && + !hasEmbeddingMarker(model.name) + ); +} + +export type ModelsDevImportInvalidReason = + | 'unknown-explicit-type' + | 'proprietary-sdk' + | 'empty-base-url' + | 'placeholder-base-url'; + +export type ModelsDevImportResolution = + | { + readonly kind: 'ok'; + readonly wire: ProviderType; + readonly guessed: boolean; + readonly baseUrl?: string; + } + | { + readonly kind: 'needs-base-url'; + readonly wire: ProviderType; + readonly guessed: boolean; + } + | { + readonly kind: 'invalid'; + readonly reason: ModelsDevImportInvalidReason; + }; + +export function resolveModelsDevImport( + entry: ModelsDevProviderEntry, + userBaseUrl?: string, +): ModelsDevImportResolution { + const wire = resolveModelsDevWire(entry); + if (wire === undefined) { + return { + kind: 'invalid', + reason: + typeof entry.type === 'string' && entry.type.length > 0 + ? 'unknown-explicit-type' + : 'proprietary-sdk', + }; + } + const guessed = inferDeclaredWireType(entry) === undefined; + + if (userBaseUrl !== undefined) { + const trimmed = userBaseUrl.trim(); + if (trimmed.length === 0) return { kind: 'invalid', reason: 'empty-base-url' }; + if (trimmed.includes('${')) return { kind: 'invalid', reason: 'placeholder-base-url' }; + return { kind: 'ok', wire, guessed, baseUrl: adaptBaseUrlForWire(trimmed, wire) }; + } + + const catalogUrl = modelsDevBaseUrl(entry, wire); + if (catalogUrl !== undefined) return { kind: 'ok', wire, guessed, baseUrl: catalogUrl }; + if (modelsDevEndpointRequired(entry, wire)) return { kind: 'needs-base-url', wire, guessed }; + return { kind: 'ok', wire, guessed }; +} + +function resolveModelsDevWire(entry: ModelsDevProviderEntry): ProviderType | undefined { + if (isWireType(entry.type)) return entry.type; + if (typeof entry.type === 'string' && entry.type.length > 0) return undefined; + const declared = inferDeclaredWireType(entry); + if (declared !== undefined) return declared; + const npm = (entry.npm ?? '').toLowerCase(); + if (npm.includes('amazon-bedrock') || npm.includes('cohere')) return undefined; + return 'openai'; +} + +function inferDeclaredWireType(entry: ModelsDevProviderEntry): ProviderType | undefined { + if (isWireType(entry.type)) return entry.type; + const npm = (entry.npm ?? '').toLowerCase(); + const id = (entry.id ?? '').toLowerCase(); + if (npm.includes('anthropic') || id.includes('anthropic') || id.includes('claude')) { + return 'anthropic'; + } + if (id.includes('vertex')) return 'vertexai'; + if (npm.includes('google') || id.includes('google') || id.includes('gemini')) { + return 'google-genai'; + } + if (npm.includes('openai') || id.includes('openai')) return 'openai'; + return undefined; +} + +export function modelsDevBaseUrl( + entry: ModelsDevProviderEntry, + wire: ProviderType, +): string | undefined { + const api = entry.api; + if (typeof api !== 'string' || api.length === 0 || api.includes('${')) return undefined; + return adaptBaseUrlForWire(api, wire); +} + +export function adaptBaseUrlForWire(baseUrl: string, wire: ProviderType): string { + return wire === 'anthropic' ? baseUrl.replace(/\/v1\/?$/, '') : baseUrl; +} + +function modelsDevEndpointRequired(entry: ModelsDevProviderEntry, wire: ProviderType): boolean { + if (typeof entry.api === 'string' && entry.api.length > 0) return true; + const npm = (entry.npm ?? '').toLowerCase(); + if (wire === 'openai' || wire === 'openai_responses') return npm !== '@ai-sdk/openai'; + if (wire === 'anthropic') return npm !== '@ai-sdk/anthropic'; + return false; +} + +export function modelsDevModelToCapability(model: ModelsDevModelEntry): ModelsDevModel | undefined { + if (typeof model.id !== 'string' || model.id.length === 0) return undefined; + const context = model.limit?.context; + if (typeof context !== 'number' || !Number.isInteger(context) || context <= 0) return undefined; + if (!isUsableChatModel(model)) return undefined; + const inputs = model.modalities?.input ?? []; + const output = model.limit?.output; + const thinking = modelsDevThinkingOptions(model.reasoning_options); + const input = model.limit?.input; + const maxInputTokens = + typeof input === 'number' && Number.isInteger(input) && input > 0 + ? Math.min(input, context) + : undefined; + return { + id: model.id, + name: typeof model.name === 'string' && model.name.length > 0 ? model.name : undefined, + maxOutputSize: typeof output === 'number' && output > 0 ? output : undefined, + reasoningKey: modelsDevReasoningKey(model.interleaved), + supportEfforts: thinking.efforts, + offEffort: thinking.offEffort, + alwaysThinking: thinking.alwaysThinking, + capability: { + image_in: inputs.includes('image'), + video_in: inputs.includes('video'), + audio_in: inputs.includes('audio'), + thinking: + Boolean(model.reasoning) || thinking.efforts !== undefined || thinking.hasToggle, + tool_use: model.tool_call ?? true, + max_context_tokens: context, + max_input_tokens: maxInputTokens, + dynamically_loaded_tools: model.dynamically_loaded_tools === true, + }, + }; +} + +function modelsDevThinkingOptions(options: ModelsDevModelEntry['reasoning_options']): { + readonly efforts: readonly string[] | undefined; + readonly offEffort: string | undefined; + readonly hasToggle: boolean; + readonly alwaysThinking: boolean | undefined; +} { + if (!Array.isArray(options)) { + return { efforts: undefined, offEffort: undefined, hasToggle: false, alwaysThinking: undefined }; + } + let efforts: readonly string[] | undefined; + let offEffort: string | undefined; + let hasToggle = false; + for (const option of options) { + if (option?.type === 'toggle') { + hasToggle = true; + continue; + } + if (option?.type !== 'effort' || !Array.isArray(option.values)) continue; + const hasNullTier = (option.values as unknown[]).some((value) => value === null); + const levels = (option.values as unknown[]).filter( + (value: unknown): value is string => typeof value === 'string' && value.length > 0, + ); + const off = levels.find((value) => value.toLowerCase() === 'none'); + if (off !== undefined) offEffort = off; + else if (hasNullTier) offEffort = 'none'; + const selectable = levels.filter((value) => value.toLowerCase() !== 'none'); + if (selectable.length > 0) efforts = selectable; + } + const alwaysThinking = + efforts !== undefined && offEffort === undefined && !hasToggle ? true : undefined; + return { efforts, offEffort, hasToggle, alwaysThinking }; +} + +function modelsDevReasoningKey(interleaved: ModelsDevModelEntry['interleaved']): string | undefined { + if (typeof interleaved !== 'object' || interleaved === null) return undefined; + const field = interleaved.field?.trim(); + return field !== undefined && field.length > 0 ? field : undefined; +} + +export function modelsDevProviderModels(entry: ModelsDevProviderEntry): ModelsDevModel[] { + const providerWire = resolveModelsDevWire(entry); + return Object.values(entry.models ?? {}) + .map((raw) => applyModelProviderOverride(modelsDevModelToCapability(raw), raw, entry, providerWire)) + .filter((model): model is ModelsDevModel => model !== undefined) + .map((model) => { + const protocol = model.protocol ?? providerWire; + if (model.alwaysThinking === true && wireHasProtocolThinkingDisable(protocol)) { + const { alwaysThinking: _dropped, ...rest } = model; + return rest as ModelsDevModel; + } + return model; + }); +} + +function applyModelProviderOverride( + model: ModelsDevModel | undefined, + raw: ModelsDevModelEntry, + entry: ModelsDevProviderEntry, + providerWire: ProviderType | undefined, +): ModelsDevModel | undefined { + if (model === undefined) return undefined; + const override = raw.provider; + if (override === undefined) return model; + const overrideNpm = typeof override.npm === 'string' ? override.npm.toLowerCase() : undefined; + if ( + overrideNpm !== undefined && + (overrideNpm.includes('amazon-bedrock') || overrideNpm.includes('cohere')) + ) { + return undefined; + } + const overrideWire = + overrideNpm !== undefined ? (inferOverrideWire(overrideNpm) ?? 'openai') : providerWire; + if (overrideWire === undefined) return model; + const rawApi = override.api; + const api = rawApi ?? entry.api; + const usableApi = + typeof api === 'string' && api.length > 0 && !api.includes('${') ? api : undefined; + + if (overrideWire === providerWire) { + if (typeof rawApi === 'string' && rawApi.includes('${')) return undefined; + if (usableApi !== undefined && usableApi !== entry.api) { + return { ...model, baseUrl: adaptBaseUrlForWire(usableApi, overrideWire) }; + } + return model; + } + + if (overrideWire === 'anthropic' && usableApi !== undefined) { + return { ...model, protocol: 'anthropic', baseUrl: adaptBaseUrlForWire(usableApi, 'anthropic') }; + } + return undefined; +} + +function inferOverrideWire(npm: string): ProviderType | undefined { + const normalized = npm.toLowerCase(); + if (normalized.includes('anthropic')) return 'anthropic'; + if (normalized.includes('vertex')) return 'vertexai'; + if (normalized.includes('google')) return 'google-genai'; + if (normalized.includes('openai')) return 'openai'; + return undefined; +} diff --git a/packages/agent-core-v2/src/app/kosongConfig/modelsDevImport.ts b/packages/agent-core-v2/src/app/kosongConfig/modelsDevImport.ts new file mode 100644 index 0000000000000000000000000000000000000000..43528456bf1256c3eb3d29ea6f69dca23334fdb1 --- /dev/null +++ b/packages/agent-core-v2/src/app/kosongConfig/modelsDevImport.ts @@ -0,0 +1,62 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ProviderCatalogItem } from '#/llm-adapter/model/catalog'; + +export interface ModelsDevModelItem { + readonly id: string; + readonly name?: string; + readonly max_context_size: number; + readonly capabilities?: readonly string[]; + readonly reasoning: boolean; +} + +export interface ModelsDevProviderItem { + readonly id: string; + readonly name: string; + readonly wire_type: string | null; + readonly guessed: boolean; + readonly needs_base_url: boolean; + readonly rejected: boolean; + readonly reject_reason: string | null; + readonly env_key: string | null; + readonly models: readonly ModelsDevModelItem[]; +} + +export const PROVIDER_ID_PATTERN = /^[\p{L}\p{N}][\p{L}\p{N}\-_ ]*$/u; + +export interface ImportModelsDevProviderOptions { + readonly catalogId: string; + readonly id?: string; + readonly apiKey?: string; + readonly baseUrl?: string; +} + +export interface ImportModelsDevProviderResult { + readonly provider: ProviderCatalogItem; + readonly modelsImported: number; +} + +export interface ImportCustomRegistryOptions { + readonly url: string; + readonly apiKey?: string; +} + +export interface ImportCustomRegistryResult { + readonly providers: readonly ProviderCatalogItem[]; + readonly modelsImported: number; +} + +export interface IModelsDevImportService { + readonly _serviceBrand: undefined; + + listModelsDevProviders(): Promise; + getModelsDevProvider(catalogId: string): Promise; + importModelsDevProvider( + options: ImportModelsDevProviderOptions, + ): Promise; + importCustomRegistry( + options: ImportCustomRegistryOptions, + ): Promise; +} + +export const IModelsDevImportService: ServiceIdentifier = + createDecorator('modelsDevImport'); diff --git a/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts b/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts new file mode 100644 index 0000000000000000000000000000000000000000..c3c2eceb6191d1f66124a58501144ccdfe1aad6d --- /dev/null +++ b/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts @@ -0,0 +1,313 @@ +import { + applyCustomRegistryProvider, + fetchCustomRegistry, + removeCustomRegistryProvider, + type CustomRegistryProviderEntry, + type CustomRegistrySource, + type ManagedKimiConfigShape, +} from '@moonshot-ai/kimi-code-oauth'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Error2 } from '#/_base/errors/errors'; +import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; +import { IConfigService } from '#/app/config/config'; +import { IModelCatalog } from '#/llm-adapter/model/catalog'; +import { type ModelsSection } from '#/llm-adapter/model/model'; +import { type ProviderConfig, type ProvidersSection } from '#/llm-adapter/provider/provider'; +import { modelsDevProviderModels, resolveModelsDevImport } from './modelsDev'; + +import { DEFAULT_MODEL_SECTION, MODELS_SECTION, PROVIDERS_SECTION } from './configSection'; +import { ModelsDevImportErrors } from './errors'; +import { IKosongConfigService } from './kosongConfig'; +import { + IModelsDevImportService, + PROVIDER_ID_PATTERN, + type ImportCustomRegistryOptions, + type ImportCustomRegistryResult, + type ImportModelsDevProviderOptions, + type ImportModelsDevProviderResult, + type ModelsDevProviderItem, +} from './modelsDevImport'; +import { + getModelsDevCatalog, + modelsDevEntry, + modelsDevModelToRecord, + toModelsDevProviderItem, + upstreamFetch, + UPSTREAM_FETCH_TIMEOUT_MS, +} from './modelsDevUpstream'; + +const codes = ModelsDevImportErrors.codes; + +export class ModelsDevImportService implements IModelsDevImportService { + declare readonly _serviceBrand: undefined; + + private writeChain: Promise = Promise.resolve(); + + constructor( + @IConfigService private readonly config: IConfigService, + @IKosongConfigService private readonly kosongConfig: IKosongConfigService, + @IModelCatalog private readonly modelCatalog: IModelCatalog, + @IAgentIdentity private readonly identity: IAgentIdentity, + ) {} + + private async outboundUserAgent(): Promise { + return (await this.identity.resolved()).outboundUserAgent; + } + + async listModelsDevProviders(): Promise { + const catalog = await getModelsDevCatalog(await this.outboundUserAgent()); + return Object.entries(catalog).map(([id, entry]) => toModelsDevProviderItem(id, entry)); + } + + async getModelsDevProvider(catalogId: string): Promise { + const catalog = await getModelsDevCatalog(await this.outboundUserAgent()); + const entry = modelsDevEntry(catalog, catalogId); + if (entry === undefined) { + throw new Error2( + codes.CATALOG_ENTRY_NOT_FOUND, + `catalog entry ${catalogId} does not exist`, + ); + } + return toModelsDevProviderItem(catalogId, entry); + } + + importModelsDevProvider( + options: ImportModelsDevProviderOptions, + ): Promise { + return this.enqueueWrite(() => this.doImportModelsDevProvider(options)); + } + + importCustomRegistry( + options: ImportCustomRegistryOptions, + ): Promise { + return this.enqueueWrite(() => this.doImportCustomRegistry(options)); + } + + private enqueueWrite(task: () => Promise): Promise { + const run = this.writeChain.then(task, task); + this.writeChain = run.then( + () => undefined, + () => undefined, + ); + return run; + } + + private async readyConfig(): Promise { + await this.config.ready; + await this.kosongConfig.ready; + return this.config; + } + + private async doImportModelsDevProvider( + options: ImportModelsDevProviderOptions, + ): Promise { + const { catalogId } = options; + const catalog = await getModelsDevCatalog(await this.outboundUserAgent()); + const entry = modelsDevEntry(catalog, catalogId); + if (entry === undefined) { + throw new Error2( + codes.CATALOG_ENTRY_NOT_FOUND, + `catalog entry ${catalogId} does not exist`, + ); + } + + const resolution = resolveModelsDevImport(entry, options.baseUrl); + if (resolution.kind === 'invalid') { + throw new Error2( + codes.CATALOG_IMPORT_INVALID, + `catalog entry ${catalogId} cannot be imported: ${resolution.reason}`, + ); + } + if (resolution.kind === 'needs-base-url') { + throw new Error2( + codes.CATALOG_IMPORT_INVALID, + `catalog entry ${catalogId} requires a base_url`, + ); + } + + const models = modelsDevProviderModels(entry); + if (models.length === 0) { + throw new Error2( + codes.CATALOG_IMPORT_INVALID, + `catalog entry ${catalogId} has no importable models`, + ); + } + + const targetId = options.id ?? catalogId; + if (!PROVIDER_ID_PATTERN.test(targetId)) { + throw new Error2( + codes.CATALOG_IMPORT_INVALID, + `catalog entry id ${targetId} cannot be used as a provider id`, + ); + } + + const config = await this.readyConfig(); + const providers = config.inspect(PROVIDERS_SECTION).userValue ?? {}; + const existing = providers[targetId]; + if (existing?.oauth !== undefined) { + throw new Error2( + codes.PROVIDER_OAUTH_MANAGED, + `provider ${targetId} is managed by OAuth login; use POST /oauth/logout instead`, + ); + } + + const provider: ProviderConfig = { type: resolution.wire }; + provider.baseUrl = resolution.baseUrl; + provider.apiKey = options.apiKey ?? existing?.apiKey; + await config.replace(PROVIDERS_SECTION, { ...providers, [targetId]: provider }); + + const records = config.inspect(MODELS_SECTION).userValue ?? {}; + const withoutTarget = Object.fromEntries( + Object.entries(records).filter(([, record]) => record.provider !== targetId), + ); + await config.replace(MODELS_SECTION, withoutTarget); + const nextModels = { ...withoutTarget }; + for (const model of models) { + nextModels[`${targetId}/${model.id}`] = modelsDevModelToRecord(targetId, model); + } + await config.replace(MODELS_SECTION, nextModels); + + const firstModel = models[0]; + if (firstModel !== undefined) { + await seedDefaultModelWhenUnset(config, `${targetId}/${firstModel.id}`); + } + + const imported = await this.modelCatalog.getProvider(targetId); + return { provider: imported, modelsImported: models.length }; + } + + private async doImportCustomRegistry( + options: ImportCustomRegistryOptions, + ): Promise { + const { url } = options; + const config = await this.readyConfig(); + const providers = config.inspect(PROVIDERS_SECTION).userValue ?? {}; + const source: CustomRegistrySource = { + kind: 'apiJson', + url, + apiKey: options.apiKey ?? registryKeyFromExisting(providers, url) ?? '', + }; + + let entries: Record; + try { + entries = await fetchCustomRegistry(source, { + fetchImpl: upstreamFetch(), + userAgent: await this.outboundUserAgent(), + signal: AbortSignal.timeout(UPSTREAM_FETCH_TIMEOUT_MS), + }); + } catch (err) { + throw new Error2( + codes.REGISTRY_IMPORT_INVALID, + `custom registry at ${url} cannot be imported: ${truncateUpstreamMessage(err)}`, + ); + } + if (Object.keys(entries).length === 0) { + throw new Error2( + codes.REGISTRY_IMPORT_INVALID, + `custom registry at ${url} has no importable providers`, + ); + } + + for (const entry of Object.values(entries)) { + if (providers[entry.id]?.oauth !== undefined) { + throw new Error2( + codes.PROVIDER_OAUTH_MANAGED, + `provider ${entry.id} is managed by OAuth login; use POST /oauth/logout instead`, + ); + } + } + + const removed = { + providers: { ...providers }, + models: { + ...config.inspect(MODELS_SECTION).userValue, + }, + } as ManagedKimiConfigShape; + const surviving = new Set(Object.values(entries).map((entry) => entry.id)); + for (const [providerId, provider] of Object.entries(removed.providers)) { + if (surviving.has(providerId)) continue; + if (!isRecord(provider)) continue; + if (provider['oauth'] !== undefined) continue; + const existingSource = provider['source']; + if ( + isRecord(existingSource) && + existingSource['kind'] === 'apiJson' && + existingSource['url'] === url + ) { + removeCustomRegistryProvider(removed, providerId); + } + } + for (const entry of Object.values(entries)) { + if (entry.id in removed.providers) { + removeCustomRegistryProvider(removed, entry.id); + } + } + await config.replace(PROVIDERS_SECTION, removed.providers as ProvidersSection); + await config.replace(MODELS_SECTION, (removed.models ?? {}) as ModelsSection); + + const applied = { + providers: removed.providers, + models: removed.models, + } as ManagedKimiConfigShape; + for (const entry of Object.values(entries)) { + applyCustomRegistryProvider(applied, entry, source); + } + await config.replace(PROVIDERS_SECTION, applied.providers as ProvidersSection); + await config.replace(MODELS_SECTION, (applied.models ?? {}) as ModelsSection); + + const firstEntry = Object.values(entries)[0]; + const firstModelKey = firstEntry === undefined ? undefined : Object.keys(firstEntry.models)[0]; + if (firstEntry !== undefined && firstModelKey !== undefined) { + await seedDefaultModelWhenUnset(config, `${firstEntry.id}/${firstModelKey}`); + } + + const imported = []; + for (const entry of Object.values(entries)) { + imported.push(await this.modelCatalog.getProvider(entry.id)); + } + const modelsImported = Object.values(entries).reduce( + (total, entry) => total + Object.keys(entry.models).length, + 0, + ); + return { providers: imported, modelsImported }; + } +} + +async function seedDefaultModelWhenUnset(config: IConfigService, alias: string): Promise { + const current = config.inspect(DEFAULT_MODEL_SECTION).userValue; + if (current !== undefined && current.trim() !== '') return; + await config.replace(DEFAULT_MODEL_SECTION, alias); +} + +function registryKeyFromExisting( + providers: ProvidersSection, + url: string, +): string | undefined { + for (const provider of Object.values(providers)) { + if (!isRecord(provider)) continue; + const source = provider['source']; + if (isRecord(source) && source['kind'] === 'apiJson' && source['url'] === url) { + const key = source['apiKey']; + if (typeof key === 'string' && key.length > 0) return key; + } + } + return undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function truncateUpstreamMessage(err: unknown, limit = 300): string { + const text = err instanceof Error ? err.message : String(err); + return text.length > limit ? `${text.slice(0, limit)}…` : text; +} + +registerScopedService( + LifecycleScope.App, + IModelsDevImportService, + ModelsDevImportService, + ScopeActivation.OnScopeCreated, + 'kosongConfig', +); diff --git a/packages/agent-core-v2/src/app/kosongConfig/modelsDevUpstream.ts b/packages/agent-core-v2/src/app/kosongConfig/modelsDevUpstream.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d56256de7466037a3b827667e56457b6f2221ff --- /dev/null +++ b/packages/agent-core-v2/src/app/kosongConfig/modelsDevUpstream.ts @@ -0,0 +1,205 @@ +import { CoreErrors } from '#/_base/errors/codes'; +import { BugIndicatingError, Error2 } from '#/_base/errors/errors'; +import type { ModelCapability } from '#/llm-adapter/contract/capability'; +import type { ModelRecord } from '#/llm-adapter/model/model'; + +import { BUILT_IN_MODELS_DEV_JSON } from './builtInModelsDev'; +import { ModelsDevImportErrors } from './errors'; +import { + modelsDevProviderModels, + resolveModelsDevImport, + type ModelsDevCatalog, + type ModelsDevModel, + type ModelsDevProviderEntry, +} from './modelsDev'; +import type { ModelsDevModelItem, ModelsDevProviderItem } from './modelsDevImport'; + +export const MODELS_DEV_URL = 'https://models.dev/api.json'; +const CACHE_TTL_MS = 10 * 60 * 1000; +export const UPSTREAM_FETCH_TIMEOUT_MS = 10_000; + +export function loadBuiltInModelsDevCatalog(text?: string): ModelsDevCatalog | undefined { + if (typeof text !== 'string' || text.length === 0) return undefined; + try { + return JSON.parse(text) as ModelsDevCatalog; + } catch { + return undefined; + } +} + +interface ModelsDevCacheEntry { + readonly catalog: ModelsDevCatalog; + readonly fetchedAt: number; +} + +let cache: ModelsDevCacheEntry | undefined; +let inFlight: Promise | undefined; +let builtInMemo: ModelsDevCatalog | undefined | null = null; +let fetchImpl: typeof fetch = fetch; +let nowImpl: () => number = Date.now; + +export function setModelsDevUpstreamForTest(options: { + fetchImpl?: typeof fetch; + now?: () => number; +}): void { + if (options.fetchImpl !== undefined) fetchImpl = options.fetchImpl; + if (options.now !== undefined) nowImpl = options.now; +} + +export function resetModelsDevUpstreamForTest(): void { + cache = undefined; + inFlight = undefined; + builtInMemo = null; + fetchImpl = fetch; + nowImpl = Date.now; +} + +export function upstreamFetch(): typeof fetch { + return fetchImpl; +} + +export async function getModelsDevCatalog(userAgent: string): Promise { + const now = nowImpl(); + if (cache !== undefined && now - cache.fetchedAt < CACHE_TTL_MS) return cache.catalog; + inFlight ??= fetchAndCache(userAgent).finally(() => { + inFlight = undefined; + }); + return inFlight; +} + +async function fetchAndCache(userAgent: string): Promise { + const now = nowImpl(); + try { + const res = await fetchImpl(MODELS_DEV_URL, { + headers: { Accept: 'application/json', 'User-Agent': userAgent }, + signal: AbortSignal.timeout(UPSTREAM_FETCH_TIMEOUT_MS), + }); + if (!res.ok) { + throw new Error2(CoreErrors.codes.INTERNAL, `HTTP ${res.status}`, { + details: { status: res.status }, + }); + } + const payload: unknown = await res.json(); + if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) { + throw new Error2(CoreErrors.codes.INTERNAL, 'unexpected catalog payload shape'); + } + cache = { catalog: payload as ModelsDevCatalog, fetchedAt: now }; + return cache.catalog; + } catch (err) { + if (cache !== undefined) return cache.catalog; + const builtIn = builtInCatalog(); + if (builtIn !== undefined) { + cache = { catalog: builtIn, fetchedAt: now }; + return builtIn; + } + throw new Error2( + ModelsDevImportErrors.codes.CATALOG_UNAVAILABLE, + `models.dev catalog unavailable: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} + +function builtInCatalog(): ModelsDevCatalog | undefined { + if (builtInMemo === null) builtInMemo = loadBuiltInModelsDevCatalog(BUILT_IN_MODELS_DEV_JSON); + return builtInMemo; +} + +export function modelsDevEntry( + catalog: ModelsDevCatalog, + id: string, +): ModelsDevProviderEntry | undefined { + return Object.prototype.hasOwnProperty.call(catalog, id) ? catalog[id] : undefined; +} + +function capabilityToStrings(capability: ModelCapability): string[] | undefined { + const caps: string[] = []; + if (capability.image_in) caps.push('image_in'); + if (capability.video_in) caps.push('video_in'); + if (capability.audio_in) caps.push('audio_in'); + if (capability.thinking) caps.push('thinking'); + if (capability.tool_use) caps.push('tool_use'); + if (capability.dynamically_loaded_tools === true) caps.push('dynamically_loaded_tools'); + return caps.length > 0 ? caps : undefined; +} + +function toModelItem(model: ModelsDevModel): ModelsDevModelItem { + const caps = capabilityToStrings(model.capability); + return { + ...(model.name !== undefined ? { name: model.name } : {}), + ...(caps !== undefined ? { capabilities: caps } : {}), + id: model.id, + max_context_size: model.capability.max_context_tokens, + reasoning: model.capability.thinking, + }; +} + +export function toModelsDevProviderItem( + id: string, + entry: ModelsDevProviderEntry, +): ModelsDevProviderItem { + const resolution = resolveModelsDevImport(entry); + const models = modelsDevProviderModels(entry).map(toModelItem); + const base = { + id, + name: entry.name || id, + env_key: entry.env?.[0] ?? null, + models, + }; + switch (resolution.kind) { + case 'ok': + return { + ...base, + wire_type: resolution.wire, + guessed: resolution.guessed, + needs_base_url: false, + rejected: false, + reject_reason: null, + }; + case 'needs-base-url': + return { + ...base, + wire_type: resolution.wire, + guessed: resolution.guessed, + needs_base_url: true, + rejected: false, + reject_reason: null, + }; + case 'invalid': + return { + ...base, + wire_type: null, + guessed: false, + needs_base_url: false, + rejected: true, + reject_reason: resolution.reason, + }; + } + throw new BugIndicatingError( + `unhandled models.dev import resolution: ${JSON.stringify(resolution)}`, + ); +} + +export function modelsDevModelToRecord(providerId: string, model: ModelsDevModel): ModelRecord { + const caps = capabilityToStrings(model.capability); + const capabilities = + model.alwaysThinking === true + ? caps?.map((cap) => (cap === 'thinking' ? 'always_thinking' : cap)) + : caps; + const record: ModelRecord = { + provider: providerId, + model: model.id, + maxContextSize: model.capability.max_context_tokens, + }; + if (model.capability.max_input_tokens !== undefined) { + record.maxInputSize = model.capability.max_input_tokens; + } + if (model.maxOutputSize !== undefined) record.maxOutputSize = model.maxOutputSize; + if (capabilities !== undefined) record.capabilities = capabilities; + if (model.name !== undefined) record.displayName = model.name; + if (model.reasoningKey !== undefined) record.reasoningKey = model.reasoningKey; + if (model.supportEfforts !== undefined) record.supportEfforts = [...model.supportEfforts]; + if (model.offEffort !== undefined) record.offEffort = model.offEffort; + if (model.protocol !== undefined) record.protocol = model.protocol; + if (model.baseUrl !== undefined) record.baseUrl = model.baseUrl; + return record; +} diff --git a/packages/agent-core-v2/src/app/kosongConfig/oauthTokenAdapter.ts b/packages/agent-core-v2/src/app/kosongConfig/oauthTokenAdapter.ts new file mode 100644 index 0000000000000000000000000000000000000000..4d870c1a4da1e7787e0dac387f801c1c89ba807c --- /dev/null +++ b/packages/agent-core-v2/src/app/kosongConfig/oauthTokenAdapter.ts @@ -0,0 +1,54 @@ +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Error2 } from '#/_base/errors/errors'; + +import { IOAuthService } from '#/app/auth/auth'; +import { AuthErrors } from '#/app/auth/errors'; +import { nonEmpty } from '#/llm-adapter/model/model-auth'; +import { IModelOAuthTokens } from '#/llm-adapter/model/model-oauth'; +import type { OAuthRef } from '#/llm-adapter/provider/provider'; + +export class ModelOAuthTokenAdapter implements IModelOAuthTokens { + declare readonly _serviceBrand: undefined; + + constructor(@IOAuthService private readonly oauth: IOAuthService) {} + + async hasCachedAccessToken(provider: string, oauthRef: OAuthRef): Promise { + try { + const token = await this.oauth.getCachedAccessToken(provider, oauthRef); + return nonEmpty(token) !== undefined; + } catch { + return false; + } + } + + async getAccessToken( + provider: string, + oauthRef: OAuthRef, + options?: { readonly force?: boolean }, + ): Promise { + const tokenProvider = this.oauth.resolveTokenProvider(provider, oauthRef); + if (tokenProvider === undefined) throw loginRequired(provider); + const token = await tokenProvider.getAccessToken( + options?.force === true ? { force: true } : undefined, + ); + if (token.trim().length === 0) throw loginRequired(provider); + return token; + } +} + +function loginRequired(providerKey: string): Error2 { + return new Error2( + AuthErrors.codes.AUTH_LOGIN_REQUIRED, + `OAuth provider "${providerKey}" requires login before it can be used.`, + ); +} + +registerScopedService( +LifecycleScope.App, + IModelOAuthTokens, + ModelOAuthTokenAdapter, + ScopeActivation.OnScopeCreated, + 'kosongConfig', +); diff --git a/packages/agent-core-v2/src/app/mcpManagement/mcpManagement.ts b/packages/agent-core-v2/src/app/mcpManagement/mcpManagement.ts new file mode 100644 index 0000000000000000000000000000000000000000..8755b26831a1a4c02b88d56bcabecd0b364183c8 --- /dev/null +++ b/packages/agent-core-v2/src/app/mcpManagement/mcpManagement.ts @@ -0,0 +1,130 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +import type { McpServerConfig } from '#/mcpCore/config-schema'; +import type { McpServerConfigView } from '#/mcpCore/configView'; +import type { + McpRegistryPluginOrigin, + McpRegistryQuery, + McpServerSource, +} from '#/app/mcpRegistry/mcpRegistry'; + +export type GlobalMcpServerConfig = McpServerConfig & { readonly name: string }; + +export interface McpManagedServer { + readonly name: string; + readonly config: McpServerConfig | McpServerConfigView; + readonly source: McpServerSource; + readonly origin: string; + readonly mutable: boolean; + readonly plugin?: McpRegistryPluginOrigin; +} + +export interface McpServerTestTarget { + readonly name?: string; + readonly server?: GlobalMcpServerConfig; + readonly cwd?: string; +} + +export interface McpServerTestResult { + readonly success: boolean; + readonly output: string; +} + +export type McpServerLocator = + | { readonly source: 'global'; readonly name: string } + | { readonly source: 'plugin'; readonly pluginId: string; readonly serverName: string }; + +export interface McpServerDescriptor { + readonly serverId: string; + readonly locator: McpServerLocator; + readonly runtimeName: string; + readonly canonicalUrl?: string; + readonly origin: McpServerSource; + readonly config: McpServerConfigView; + readonly enabled: boolean; + readonly editable: boolean; +} + +export type McpServerAuthState = + | 'not-applicable' + | 'bearer-token' + | 'oauth-required' + | 'oauth-authorized' + | 'oauth-expired' + | 'unavailable'; + +export interface McpServerInspection extends McpServerDescriptor { + readonly authStatus: McpServerAuthState; + readonly checkedAt?: number; + readonly error?: string; +} + +export interface McpServerAuthStatus { + readonly name: string; + readonly authStatus: McpServerAuthState; +} + +export type McpServerAuthBeginResult = + | { + readonly status: 'authorization-required'; + readonly flowId: string; + readonly authorizationUrl: string; + } + | { readonly status: 'already-authorized' }; + +export interface McpServerAuthFlowHandle { + readonly flowId: string; + readonly timeoutMs?: number; +} + +export interface McpAuthStatusQuery extends McpRegistryQuery { + readonly verify?: boolean; +} + +export interface IMcpManagementService { + readonly _serviceBrand: undefined; + + listServers(query?: McpRegistryQuery): Promise; + + getServer(name: string, query?: McpRegistryQuery): Promise; + + addServer( + server: GlobalMcpServerConfig, + query?: McpRegistryQuery, + ): Promise; + + updateServer( + server: GlobalMcpServerConfig, + query?: McpRegistryQuery, + ): Promise; + + removeServer(name: string, query?: McpRegistryQuery): Promise; + + testServer(target: McpServerTestTarget): Promise; + + listAuthStatuses(query?: McpAuthStatusQuery): Promise; + + inspectServers( + targets?: readonly McpServerLocator[], + query?: McpRegistryQuery, + ): Promise; + + resolveServerByName(name: string, query?: McpRegistryQuery): Promise; + + beginServerAuth( + locator: McpServerLocator, + query?: McpRegistryQuery, + ): Promise; + + completeServerAuth( + handle: McpServerAuthFlowHandle, + options?: { readonly signal?: AbortSignal }, + ): Promise; + + cancelServerAuth(handle: Pick): Promise; + + resetServerAuth(locator: McpServerLocator, query?: McpRegistryQuery): Promise; +} + +export const IMcpManagementService: ServiceIdentifier = + createDecorator('mcpManagementService'); diff --git a/packages/agent-core-v2/src/app/mcpManagement/mcpManagementService.ts b/packages/agent-core-v2/src/app/mcpManagement/mcpManagementService.ts new file mode 100644 index 0000000000000000000000000000000000000000..4e26e518d9a04a8b355c41e088e6940118945895 --- /dev/null +++ b/packages/agent-core-v2/src/app/mcpManagement/mcpManagementService.ts @@ -0,0 +1,651 @@ +import { randomUUID } from 'node:crypto'; + +import { normalize } from 'pathe'; + +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; + +import { ErrorCodes, Error2 } from '#/errors'; +import { McpConnectionManager } from '#/mcpCore/connection-manager'; +import { McpServerConfigSchema, type McpServerConfig } from '#/mcpCore/config-schema'; +import { toMcpServerConfigView } from '#/mcpCore/configView'; +import { + AlreadyAuthorizedError, + type BeginAuthorizationResult, + type McpOAuthService, + type McpOAuthTokenState, +} from '#/mcpCore/oauth/service'; +import { canonicalMcpOAuthResource } from '#/mcpCore/oauth/store'; +import { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import { IHostProcessService } from '#/os/interface/hostProcess'; +import { LocalRuntime } from '#/runtime/localRuntime'; +import { RuntimeRegistry } from '#/runtime/runtimeRegistry'; +import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; +import { IConfigService } from '#/app/config/config'; +import { MCP_SECTION, type McpSection } from '#/app/mcpConfig/configSection'; +import { IMcpConfigStore, normalizeServerName } from '#/app/mcpConfig/configStore'; +import { IMcpOAuthService } from '#/app/mcpConfig/oauthService'; +import { + IMcpRegistryService, + type McpRegistryEntry, + type McpRegistryQuery, +} from '#/app/mcpRegistry/mcpRegistry'; +import { + IRuntimeResolver, + IWorkspaceInstanceManager, +} from '#/workspace/workspaceInstance/workspaceInstanceManager'; + +import { + IMcpManagementService, + type GlobalMcpServerConfig, + type McpAuthStatusQuery, + type McpManagedServer, + type McpServerAuthBeginResult, + type McpServerAuthFlowHandle, + type McpServerAuthState, + type McpServerAuthStatus, + type McpServerDescriptor, + type McpServerInspection, + type McpServerLocator, + type McpServerTestResult, + type McpServerTestTarget, +} from './mcpManagement'; + +const DEFAULT_AUTH_TIMEOUT_MS = 15 * 60_000; +const AUTH_FLOW_IDLE_TIMEOUT_MS = 15 * 60_000; +const MAX_AUTH_TIMEOUT_MS = 2 ** 31 - 1; + +export class McpManagementService extends Disposable implements IMcpManagementService { + declare readonly _serviceBrand: undefined; + + private readonly authFlows = new Map< + string, + { flow: BeginAuthorizationResult; idleTimer: NodeJS.Timeout } + >(); + + constructor( + @IMcpRegistryService private readonly registry: IMcpRegistryService, + @IMcpConfigStore private readonly store: IMcpConfigStore, + @IMcpOAuthService private readonly oauth: McpOAuthService, + @IConfigService private readonly config: IConfigService, + @IAgentIdentity private readonly identity: IAgentIdentity, + @IRuntimeResolver private readonly runtimeResolver: IRuntimeResolver, + @IWorkspaceInstanceManager private readonly workspaceInstances: IWorkspaceInstanceManager, + @IHostEnvironment private readonly hostEnvironment: IHostEnvironment, + @IHostProcessService private readonly hostProcess: IHostProcessService, + @ILogService private readonly log: ILogService, + ) { + super(); + } + + async listServers(query: McpRegistryQuery = {}): Promise { + return (await this.registry.list(query)).map(toManagedServer); + } + + async getServer(name: string, query: McpRegistryQuery = {}): Promise { + return toManagedServer(await this.registry.get(name, query)); + } + + async addServer( + server: GlobalMcpServerConfig, + query: McpRegistryQuery = {}, + ): Promise { + const name = normalizeServerName(server.name); + await this.guardMutation(name, query); + await this.store.add({ ...server, name }); + return this.listServers(query); + } + + async updateServer( + server: GlobalMcpServerConfig, + query: McpRegistryQuery = {}, + ): Promise { + const name = normalizeServerName(server.name); + await this.guardMutation(name, query); + await this.store.update({ ...server, name }); + return this.listServers(query); + } + + async removeServer( + name: string, + query: McpRegistryQuery = {}, + ): Promise { + const normalized = normalizeServerName(name); + await this.guardMutation(normalized, query); + await this.store.remove(normalized); + return this.listServers(query); + } + + async testServer(target: McpServerTestTarget): Promise { + await this.waitForReadiness(); + const resolved = await this.resolveTestTarget(target); + return this.withProbe(resolved, target.cwd, (manager) => + standaloneTestResult(resolved.name, manager), + ); + } + + private async guardMutation(name: string, query: McpRegistryQuery): Promise { + const matches = (await this.registry.list(query)).filter((entry) => entry.name === name); + for (const entry of matches) { + if (entry.source === 'global' && !entry.mutable) throwReadOnlyMcpServer(entry); + } + } + + private async resolveTestTarget(target: McpServerTestTarget): Promise { + const { name, server, cwd } = target; + if (server !== undefined) { + if (name !== undefined && name !== server.name) { + throw new Error2( + ErrorCodes.REQUEST_INVALID, + 'Pass either an MCP server name or an inline server config, not both', + ); + } + const parsed = McpServerConfigSchema.safeParse(server); + if (!parsed.success) { + throw new Error2( + ErrorCodes.CONFIG_INVALID, + `Invalid MCP server "${server.name}": ${parsed.error.message}`, + ); + } + return { name: server.name, ...parsed.data }; + } + if (name === undefined) { + throw new Error2( + ErrorCodes.REQUEST_INVALID, + 'Pass an MCP server name or an inline server config', + ); + } + const matches = (await this.registry.list({ cwd })).filter((entry) => entry.name === name); + if (matches.length === 0) { + throw new Error2(ErrorCodes.MCP_SERVER_NOT_FOUND, `MCP server "${name}" was not found`); + } + const enabled = matches.filter((entry) => entry.config.enabled !== false); + if (enabled.length > 1) { + throw new Error2( + ErrorCodes.REQUEST_INVALID, + `MCP runtime name "${name}" is shared by multiple enabled servers`, + ); + } + const entry = enabled[0] ?? matches[0]!; + return { name: entry.name, ...entry.config }; + } + + private async withProbe( + server: GlobalMcpServerConfig, + cwd: string | undefined, + inspect: (manager: McpConnectionManager) => T, + ): Promise { + await this.waitForReadiness(); + const section = this.config.get(MCP_SECTION); + let workspaceId: string | undefined; + let stdioCwd = cwd; + let runtimeResolver = this.runtimeResolver; + let transientRuntimes: RuntimeRegistry | undefined; + if (server.transport === 'stdio') { + stdioCwd = normalize(cwd ?? process.cwd()); + const workspace = this.workspaceInstances.findContaining(stdioCwd); + if (workspace !== undefined) { + workspaceId = workspace.id; + } else { + const runtimeId = server.runtime_id; + if (runtimeId !== undefined && runtimeId !== 'local') { + throw new Error2( + ErrorCodes.REQUEST_INVALID, + `Cannot probe MCP server "${server.name}" with runtime_id "${runtimeId}": no materialized workspace contains ${stdioCwd}, and an out-of-workspace probe only supports the local runtime`, + ); + } + await this.hostEnvironment.ready; + workspaceId = `mcp-probe-${randomUUID()}`; + transientRuntimes = new RuntimeRegistry(workspaceId); + transientRuntimes.register( + new LocalRuntime( + workspaceId, + this.hostEnvironment, + undefined, + this.hostProcess, + undefined, + ), + ); + runtimeResolver = { + _serviceBrand: undefined, + inspect: (binding) => transientRuntimes!.inspect(binding), + acquire: (binding, required) => transientRuntimes!.acquire(binding, required), + }; + } + } + const manager = new McpConnectionManager({ + log: this.log, + stdioCwd, + runtimeResolver, + workspaceId, + runtimeId: workspaceId === undefined ? undefined : 'local', + oauthService: this.oauth, + resolveClientName: () => this.identity.current().slug, + resolveDefaultTimeouts: () => ({ + startupTimeoutMs: section?.startupTimeoutMs, + toolTimeoutMs: section?.toolTimeoutMs, + }), + }); + try { + await manager.connectAll({ [server.name]: mcpConfigWithoutName(server) }); + return inspect(manager); + } finally { + try { + await manager.shutdown(); + } finally { + await transientRuntimes?.dispose(); + } + } + } + + async listAuthStatuses(query: McpAuthStatusQuery = {}): Promise { + await this.waitForReadiness(); + const entries = await this.registry.list({ cwd: query.cwd }); + return Promise.all( + entries.map(async (entry) => ({ + name: entry.name, + authStatus: await this.serverAuthState(entry, query.cwd, query.verify), + })), + ); + } + + async inspectServers( + targets?: readonly McpServerLocator[], + query: McpRegistryQuery = {}, + ): Promise { + await this.waitForReadiness(); + const catalog = await this.serverDescriptors(query); + const descriptors = selectServerDescriptors(catalog, targets); + const inspections = await this.inspectServerDescriptors(descriptors, catalog); + return inspections.map((inspection) => ({ + ...inspection, + config: toMcpServerConfigView(inspection.config), + })); + } + + async resolveServerByName(name: string, query: McpRegistryQuery = {}): Promise { + await this.registry.get(name, query); + const catalog = await this.serverDescriptors(query); + const matches = catalog.filter((candidate) => candidate.runtimeName === name); + const descriptor = matches.find((candidate) => candidate.enabled) ?? matches[0]!; + this.requireUnambiguousRuntimeName(catalog, descriptor); + return descriptor.locator; + } + + async beginServerAuth( + locator: McpServerLocator, + query: McpRegistryQuery = {}, + ): Promise { + await this.waitForReadiness(); + const server = await this.resolveServer(locator, query); + const config = requireOAuthMcpConfig(server.runtimeName, server.config); + try { + const flow = await this.oauth.beginAuthorization(server.runtimeName, config.url); + const flowId = randomUUID(); + const idleTimer = setTimeout(() => { + const expired = this.authFlows.get(flowId); + this.authFlows.delete(flowId); + void expired?.flow.cancel(); + }, AUTH_FLOW_IDLE_TIMEOUT_MS); + idleTimer.unref(); + this.authFlows.set(flowId, { flow, idleTimer }); + return { + status: 'authorization-required', + flowId, + authorizationUrl: flow.authorizationUrl.toString(), + }; + } catch (error) { + if (error instanceof AlreadyAuthorizedError) { + return { status: 'already-authorized' }; + } + throw error; + } + } + + async completeServerAuth( + handle: McpServerAuthFlowHandle, + options?: { readonly signal?: AbortSignal }, + ): Promise { + if ( + handle.timeoutMs !== undefined && + (!Number.isInteger(handle.timeoutMs) || + handle.timeoutMs < 1 || + handle.timeoutMs > MAX_AUTH_TIMEOUT_MS) + ) { + throw new Error2( + ErrorCodes.REQUEST_INVALID, + `MCP OAuth timeoutMs must be an integer between 1 and ${MAX_AUTH_TIMEOUT_MS}`, + ); + } + const active = this.authFlows.get(handle.flowId); + if (active === undefined) { + throw new Error2(ErrorCodes.REQUEST_INVALID, `Unknown MCP OAuth flow: ${handle.flowId}`); + } + clearTimeout(active.idleTimer); + try { + await active.flow.complete({ + signal: options?.signal, + timeoutMs: handle.timeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS, + }); + } finally { + this.authFlows.delete(handle.flowId); + } + } + + async cancelServerAuth(handle: Pick): Promise { + const active = this.authFlows.get(handle.flowId); + if (active === undefined) return; + clearTimeout(active.idleTimer); + this.authFlows.delete(handle.flowId); + await active.flow.cancel(); + } + + override dispose(): void { + for (const active of this.authFlows.values()) { + clearTimeout(active.idleTimer); + void active.flow.cancel(); + } + this.authFlows.clear(); + super.dispose(); + } + + async resetServerAuth(locator: McpServerLocator, query: McpRegistryQuery = {}): Promise { + await this.waitForReadiness(); + const server = await this.resolveServer(locator, query); + const config = requireRemoteMcpConfig(server.runtimeName, server.config); + await this.oauth.invalidate(server.runtimeName, config.url); + } + + private async serverDescriptors( + query: McpRegistryQuery = {}, + ): Promise { + return (await this.registry.list(query)).map((entry) => serverDescriptor(entry)); + } + + private async resolveServer( + locator: McpServerLocator, + query: McpRegistryQuery, + ): Promise { + const catalog = await this.serverDescriptors(query); + const server = selectServerDescriptors(catalog, [locator])[0]!; + this.requireUnambiguousRuntimeName(catalog, server); + return server; + } + + private requireUnambiguousRuntimeName( + catalog: readonly McpServerRuntimeDescriptor[], + server: McpServerRuntimeDescriptor, + ): void { + const conflict = catalog.find( + (candidate) => + candidate.serverId !== server.serverId && + candidate.enabled && + candidate.runtimeName === server.runtimeName, + ); + if (conflict !== undefined) { + throw new Error2( + ErrorCodes.REQUEST_INVALID, + `MCP runtime name "${server.runtimeName}" is shared by multiple enabled servers; use the locator-addressed RPC instead`, + ); + } + } + + private async serverAuthState( + entry: McpRegistryEntry, + cwd: string | undefined, + verify: boolean | undefined, + ): Promise { + const server = entry.config; + if (server.enabled === false) return 'not-applicable'; + if (server.transport === 'stdio') return 'not-applicable'; + if (server.bearerTokenEnvVar !== undefined) return 'bearer-token'; + if (server.headers !== undefined && server.auth !== 'oauth') return 'not-applicable'; + if (server.transport !== 'http' && server.auth !== 'oauth') return 'not-applicable'; + const tokens = await this.oauth.tokenState(entry.name, server.url); + const offline = (): McpServerAuthState => { + if (tokens.hasTokens) { + return !tokens.expired || tokens.hasRefreshToken ? 'oauth-authorized' : 'oauth-expired'; + } + return server.auth === 'oauth' ? 'oauth-required' : 'not-applicable'; + }; + + const probe = async (): Promise => + this.withProbe({ name: entry.name, ...server }, cwd, (manager) => { + const status = manager.get(entry.name)?.status; + if (status === 'connected') return tokens.hasTokens ? 'oauth-authorized' : 'not-applicable'; + if (status === 'needs-auth') return tokens.hasTokens ? 'oauth-expired' : 'oauth-required'; + return offline(); + }); + + if (verify === true) return probe(); + if (verify === false || tokens.hasTokens || server.auth === 'oauth') return offline(); + return probe(); + } + + private async inspectServerDescriptors( + descriptors: readonly McpServerRuntimeDescriptor[], + catalog: readonly McpServerRuntimeDescriptor[], + ): Promise { + const runtimeNameCounts = new Map(); + for (const server of new Map(catalog.map((item) => [item.serverId, item])).values()) { + if (!server.enabled) continue; + runtimeNameCounts.set(server.runtimeName, (runtimeNameCounts.get(server.runtimeName) ?? 0) + 1); + } + const credentialStates = new Map(); + const probeConfigs = Object.create(null) as Record; + for (const server of descriptors) { + if (configuredMcpAuthState(server) !== undefined) continue; + if (runtimeNameCounts.get(server.runtimeName) !== 1) continue; + const config = requireRemoteMcpConfig(server.runtimeName, server.config); + credentialStates.set( + server.serverId, + await this.oauth.tokenState(server.runtimeName, config.url), + ); + probeConfigs[server.runtimeName] = server.config; + } + let manager: McpConnectionManager | undefined; + try { + if (Object.keys(probeConfigs).length > 0) { + const section = this.config.get(MCP_SECTION); + manager = new McpConnectionManager({ + log: this.log, + oauthService: this.oauth, + resolveClientName: () => this.identity.current().slug, + resolveDefaultTimeouts: () => ({ + startupTimeoutMs: section?.startupTimeoutMs, + toolTimeoutMs: section?.toolTimeoutMs, + }), + }); + await manager.connectAll(probeConfigs); + } + const checkedAt = Date.now(); + return descriptors.map((server) => { + const configured = configuredMcpAuthState(server); + if (configured !== undefined) return { ...server, authStatus: configured }; + if (runtimeNameCounts.get(server.runtimeName) !== 1) { + return { + ...server, + authStatus: 'unavailable' as const, + checkedAt, + error: `MCP runtime name "${server.runtimeName}" is not unique`, + }; + } + const tokens = credentialStates.get(server.serverId); + const entry = manager?.get(server.runtimeName); + if (entry?.status === 'connected') { + return { + ...server, + authStatus: tokens?.hasTokens === true ? 'oauth-authorized' : 'not-applicable', + checkedAt, + }; + } + if (entry?.status === 'needs-auth') { + return { + ...server, + authStatus: tokens?.hasTokens === true ? 'oauth-expired' : 'oauth-required', + checkedAt, + }; + } + return { + ...server, + authStatus: 'unavailable' as const, + checkedAt, + error: entry?.error ?? `MCP server finished with status ${entry?.status ?? 'unknown'}`, + }; + }); + } finally { + await manager?.shutdown(); + } + } + + private async waitForReadiness(): Promise { + await this.config.ready; + await this.identity.resolved(); + } +} + +function throwReadOnlyMcpServer(entry: McpRegistryEntry): void { + throw new Error2( + ErrorCodes.REQUEST_INVALID, + `MCP server "${entry.name}" is read-only: it is defined in ${entry.origin} — edit that file instead`, + ); +} + +function toManagedServer(entry: McpRegistryEntry): McpManagedServer { + return { + name: entry.name, + config: entry.mutable ? entry.config : toMcpServerConfigView(entry.config), + source: entry.source, + origin: entry.origin, + mutable: entry.mutable, + plugin: entry.plugin, + }; +} + +function mcpConfigWithoutName(server: GlobalMcpServerConfig): McpServerConfig { + const { name: _name, ...config } = server; + return config; +} + +type McpRemoteServerConfig = Exclude; + +function requireRemoteMcpConfig(name: string, config: McpServerConfig): McpRemoteServerConfig { + if (config.transport !== 'stdio') return config; + throw new Error2( + ErrorCodes.REQUEST_INVALID, + `MCP server "${name}" does not use a remote transport`, + ); +} + +function requireOAuthMcpConfig(name: string, input: McpServerConfig): McpRemoteServerConfig { + const config = requireRemoteMcpConfig(name, input); + if (config.bearerTokenEnvVar !== undefined) { + throw new Error2( + ErrorCodes.REQUEST_INVALID, + `MCP server "${name}" uses a static bearer token`, + ); + } + if (config.headers !== undefined && config.auth !== 'oauth') { + throw new Error2( + ErrorCodes.REQUEST_INVALID, + `MCP server "${name}" uses static headers and is not marked for OAuth`, + ); + } + return config; +} + +export function mcpServerId(locator: McpServerLocator): string { + if (locator.source === 'global') return `global:${encodeURIComponent(locator.name)}`; + return `plugin:${encodeURIComponent(locator.pluginId)}:${encodeURIComponent(locator.serverName)}`; +} + +export function describeMcpServerLocator(locator: McpServerLocator): string { + if (locator.source === 'global') return locator.name; + return `${locator.pluginId}/${locator.serverName}`; +} + +type McpServerRuntimeDescriptor = Omit & { + readonly config: McpServerConfig; +}; + +type McpServerRuntimeInspection = McpServerRuntimeDescriptor & + Pick; + +function serverDescriptor(entry: McpRegistryEntry): McpServerRuntimeDescriptor { + const locator: McpServerLocator = + entry.source === 'plugin' && entry.plugin !== undefined + ? { source: 'plugin', pluginId: entry.plugin.id, serverName: entry.plugin.name } + : { source: 'global', name: entry.name }; + return { + serverId: mcpServerId(locator), + locator, + runtimeName: entry.name, + canonicalUrl: + entry.config.transport === 'stdio' + ? undefined + : canonicalMcpOAuthResource(entry.config.url), + origin: entry.source, + config: entry.config, + enabled: entry.config.enabled !== false, + editable: entry.mutable, + }; +} + +function selectServerDescriptors( + catalog: readonly McpServerRuntimeDescriptor[], + targets?: readonly McpServerLocator[], +): readonly McpServerRuntimeDescriptor[] { + const effectiveTargets = targets === null ? undefined : targets; + if (effectiveTargets === undefined) return catalog; + const byId = new Map(catalog.map((server) => [server.serverId, server])); + return effectiveTargets.map((target) => { + const server = byId.get(mcpServerId(target)); + if (server !== undefined) return server; + throw new Error2( + ErrorCodes.MCP_SERVER_NOT_FOUND, + `MCP server "${describeMcpServerLocator(target)}" was not found`, + ); + }); +} + +function configuredMcpAuthState( + server: McpServerRuntimeDescriptor, +): McpServerAuthState | undefined { + if (!server.enabled || server.config.enabled === false) return 'not-applicable'; + if (server.config.transport === 'stdio') return 'not-applicable'; + if (server.config.bearerTokenEnvVar !== undefined) return 'bearer-token'; + if (server.config.headers !== undefined && server.config.auth !== 'oauth') { + return 'not-applicable'; + } + return undefined; +} + +function standaloneTestResult( + name: string, + manager: McpConnectionManager, +): McpServerTestResult { + const entry = manager.get(name); + if (entry?.status !== 'connected') { + return { + success: false, + output: entry?.error ?? `MCP server "${name}" finished with status ${entry?.status ?? 'unknown'}`, + }; + } + const tools = manager.resolved(name)?.rawTools ?? []; + const lines = [ + `Connected to MCP server "${name}".`, + `Available tools: ${tools.length}`, + ...tools.map((tool) => `- ${tool.name}${tool.description ? `: ${tool.description}` : ''}`), + ]; + return { success: true, output: lines.join('\n') }; +} + +registerScopedService( + LifecycleScope.App, + IMcpManagementService, + McpManagementService, + ScopeActivation.OnDemand, + 'mcpManagement', +); diff --git a/packages/agent-core-v2/src/app/mcpRegistry/mcpRegistry.ts b/packages/agent-core-v2/src/app/mcpRegistry/mcpRegistry.ts new file mode 100644 index 0000000000000000000000000000000000000000..b3d25b916f7ac8f26d968431d6eddc7e15b3a1b7 --- /dev/null +++ b/packages/agent-core-v2/src/app/mcpRegistry/mcpRegistry.ts @@ -0,0 +1,38 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +import type { McpServerConfig } from '#/mcpCore/config-schema'; + +export type McpServerSource = 'global' | 'plugin' | 'caller'; + +export interface McpRegistryPluginOrigin { + readonly id: string; + readonly name: string; +} + +export interface McpRegistryEntry { + readonly name: string; + readonly config: McpServerConfig; + readonly source: McpServerSource; + readonly origin: string; + readonly mutable: boolean; + readonly plugin?: McpRegistryPluginOrigin; +} + +export interface McpRegistryQuery { + readonly cwd?: string; +} + +export interface IMcpRegistryService { + readonly _serviceBrand: undefined; + + list(query?: McpRegistryQuery): Promise; + + get(name: string, query?: McpRegistryQuery): Promise; + + resolveRuntimeTarget(name: string, query?: McpRegistryQuery): Promise; +} + +export const IMcpRegistryService: ServiceIdentifier = + createDecorator('mcpRegistryService'); + +export { mcpServerConfigsEqual } from '#/mcpCore/connection-manager'; diff --git a/packages/agent-core-v2/src/app/mcpRegistry/mcpRegistryService.ts b/packages/agent-core-v2/src/app/mcpRegistry/mcpRegistryService.ts new file mode 100644 index 0000000000000000000000000000000000000000..2398c1904f978c3add229219792adc6b87c2daa6 --- /dev/null +++ b/packages/agent-core-v2/src/app/mcpRegistry/mcpRegistryService.ts @@ -0,0 +1,116 @@ +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { canonicalWorkspaceRoot } from '#/_base/utils/paths'; + +import { ErrorCodes, Error2 } from '#/errors'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { loadMcpServersDetailed } from '#/app/mcpConfig/configLoader'; +import { IMcpConfigStore } from '#/app/mcpConfig/configStore'; +import { IPluginService } from '#/app/plugin/plugin'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { readWorkspaceTrust } from '#/workspace/workspaceTrust/trustRecord'; + +import { + IMcpRegistryService, + type McpRegistryEntry, + type McpRegistryQuery, +} from './mcpRegistry'; + +export class McpRegistryService implements IMcpRegistryService { + declare readonly _serviceBrand: undefined; + + constructor( + @IMcpConfigStore private readonly store: IMcpConfigStore, + @IPluginService private readonly plugins: IPluginService, + @IHostFileSystem private readonly fs: IHostFileSystem, + @IBootstrapService private readonly bootstrap: IBootstrapService, + @IAtomicDocumentStore private readonly docs: IAtomicDocumentStore, + ) {} + + async list(query: McpRegistryQuery = {}): Promise { + const out: McpRegistryEntry[] = []; + + if (query.cwd === undefined) { + const userEntries = await this.store.list(); + for (const server of userEntries) { + const { name, ...config } = server; + out.push({ + name, + config, + source: 'global', + origin: this.store.path, + mutable: true, + }); + } + } else { + const cwd = canonicalWorkspaceRoot(query.cwd); + if (!(await readWorkspaceTrust(this.docs, cwd))) { + const userEntries = await this.store.list(); + for (const server of userEntries) { + const { name, ...config } = server; + out.push({ + name, + config, + source: 'global', + origin: this.store.path, + mutable: true, + }); + } + } else { + const detailed = await loadMcpServersDetailed({ + fs: this.fs, + cwd, + homeDir: this.bootstrap.homeDir, + }); + for (const [name, config] of Object.entries(detailed.servers)) { + const origin = detailed.origins[name] ?? this.store.path; + out.push({ + name, + config, + source: 'global', + origin, + mutable: origin === this.store.path, + }); + } + } + } + + for (const entry of await this.plugins.mcpServerEntries()) { + out.push({ + name: entry.name, + config: entry.config, + source: 'plugin', + origin: entry.pluginId, + mutable: false, + plugin: { id: entry.pluginId, name: entry.serverName }, + }); + } + + return out; + } + + async get(name: string, query: McpRegistryQuery = {}): Promise { + const entry = (await this.list(query)).find((candidate) => candidate.name === name); + if (entry !== undefined) return entry; + throw new Error2(ErrorCodes.MCP_SERVER_NOT_FOUND, `MCP server "${name}" was not found`); + } + + async resolveRuntimeTarget( + name: string, + query: McpRegistryQuery = {}, + ): Promise { + const matches = (await this.list(query)).filter((entry) => entry.name === name); + const file = matches.find((entry) => entry.source === 'global'); + if (file !== undefined) return file; + return matches.find((entry) => entry.source === 'plugin' && entry.config.enabled !== false); + } +} + +registerScopedService( + LifecycleScope.App, + IMcpRegistryService, + McpRegistryService, + ScopeActivation.OnDemand, + 'mcpRegistry', +); diff --git a/packages/agent-core-v2/src/app/plugin/archive.ts b/packages/agent-core-v2/src/app/plugin/archive.ts new file mode 100644 index 0000000000000000000000000000000000000000..781bdc6ea3fc13ee4b1bd2e0da885d9d295f2b1a --- /dev/null +++ b/packages/agent-core-v2/src/app/plugin/archive.ts @@ -0,0 +1,168 @@ +import { createWriteStream } from 'node:fs'; +import { chmod, mkdir, readdir, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; + +import { type Entry, fromBuffer as yauzlFromBuffer } from 'yauzl'; + +import { Error2, ErrorCodes } from '#/errors'; + +export async function downloadZip(url: string, signal?: AbortSignal): Promise { + const controller = new AbortController(); + const timeoutHandle = setTimeout(() => { + controller.abort(); + }, 5 * 60 * 1000); + try { + const resp = await fetch(url, { signal: signal ?? controller.signal }); + if (!resp.ok) { + throw new Error2( + ErrorCodes.PLUGIN_LOAD_FAILED, + `Failed to download zip: HTTP ${resp.status} ${resp.statusText}`, + { details: { url, status: resp.status } }, + ); + } + return Buffer.from(await resp.arrayBuffer()); + } finally { + clearTimeout(timeoutHandle); + } +} + +export async function extractZip(buffer: Buffer, destDir: string): Promise { + await mkdir(destDir, { recursive: true }); + const destDirResolved = path.resolve(destDir); + let settled = false; + + await new Promise((resolve, reject) => { + yauzlFromBuffer(buffer, { lazyEntries: true }, (openErr, zipfile) => { + if (openErr !== null || zipfile === undefined) { + reject( + new Error2( + ErrorCodes.PLUGIN_LOAD_FAILED, + `Failed to open zip: ${openErr?.message ?? 'unknown error'}`, + { cause: openErr ?? undefined }, + ), + ); + return; + } + + const onEntry = (entry: Entry): void => { + const fileName = entry.fileName; + const destPath = path.resolve(destDir, fileName); + + if (destPath !== destDirResolved && !destPath.startsWith(destDirResolved + path.sep)) { + if (!settled) { + settled = true; + reject( + new Error2( + ErrorCodes.PLUGIN_LOAD_FAILED, + `Path traversal detected in zip entry: ${fileName}`, + { details: { entry: fileName } }, + ), + ); + } + zipfile.close(); + return; + } + + if (fileName.endsWith('/')) { + mkdir(destPath, { recursive: true }) + .then(() => { + zipfile.readEntry(); + }) + .catch((error) => { + if (!settled) { + settled = true; + reject(error); + } + zipfile.close(); + }); + return; + } + + zipfile.openReadStream(entry, (streamErr, stream) => { + if (streamErr !== null || stream === undefined) { + if (!settled) { + settled = true; + reject( + new Error2( + ErrorCodes.PLUGIN_LOAD_FAILED, + `Failed to read ${fileName} from archive: ${streamErr?.message ?? 'unknown error'}`, + { cause: streamErr ?? undefined, details: { entry: fileName } }, + ), + ); + } + zipfile.close(); + return; + } + + mkdir(path.dirname(destPath), { recursive: true }) + .then(() => pipeline(stream, createWriteStream(destPath))) + .then(() => restoreFilePermissions(destPath, entry)) + .then(() => { + zipfile.readEntry(); + }) + .catch((error) => { + if (!settled) { + settled = true; + reject(error); + } + zipfile.close(); + }); + }); + }; + + zipfile.on('entry', onEntry); + zipfile.on('end', () => { + if (!settled) { + settled = true; + resolve(); + } + }); + zipfile.on('error', (err: Error) => { + if (!settled) { + settled = true; + reject(err); + } + }); + zipfile.readEntry(); + }); + }); + + return detectPluginRoot(destDir); +} + +async function restoreFilePermissions(destPath: string, entry: Entry): Promise { + const mode = entry.externalFileAttributes >>> 16; + if (mode === 0) return; + const permissions = mode & 0o777; + if (permissions === 0) return; + await chmod(destPath, permissions); +} + +async function detectPluginRoot(dir: string): Promise { + if (await hasManifest(dir)) return dir; + + const entries = await readdir(dir, { withFileTypes: true }); + const childDirs = entries.filter((entry) => entry.isDirectory()); + const childDir = childDirs.length === 1 ? childDirs[0] : undefined; + if (childDir !== undefined) { + const child = path.join(dir, childDir.name); + if (await hasManifest(child)) return child; + } + + return dir; +} + +async function hasManifest(dir: string): Promise { + const rootManifest = path.join(dir, 'kimi.plugin.json'); + const dirManifest = path.join(dir, '.kimi-plugin', 'plugin.json'); + return (await isFile(rootManifest)) || (await isFile(dirManifest)); +} + +async function isFile(p: string): Promise { + try { + return (await stat(p)).isFile(); + } catch { + return false; + } +} diff --git a/packages/agent-core-v2/src/app/plugin/commands.ts b/packages/agent-core-v2/src/app/plugin/commands.ts new file mode 100644 index 0000000000000000000000000000000000000000..7dbe9c99fcf6fe25d3fa640229aea26aa04d5606 --- /dev/null +++ b/packages/agent-core-v2/src/app/plugin/commands.ts @@ -0,0 +1,74 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { parseFrontmatter } from '#/_base/text/frontmatter'; + +import type { PluginCommandDef } from './types'; + +export function parseCommandText(input: { + readonly text: string; + readonly commandPath: string; + readonly pluginId: string; + readonly fallbackName?: string; +}): PluginCommandDef { + const { text, commandPath, pluginId } = input; + const parsed = parseFrontmatter(text); + const frontmatter = isRecord(parsed.data) ? parsed.data : {}; + + const baseName = input.fallbackName ?? path.basename(commandPath).replace(/\.md$/i, ''); + const name = nonEmptyString(frontmatter['name']) ?? baseName; + + const body = parsed.body.trim(); + const description = nonEmptyString(frontmatter['description']) ?? descriptionFromBody(body); + + return { + pluginId, + name, + description, + body, + path: path.resolve(commandPath), + }; +} + +export async function loadPluginCommand(input: { + readonly commandPath: string; + readonly pluginId: string; + readonly fallbackName?: string; +}): Promise { + try { + const text = await readFile(input.commandPath, 'utf8'); + return parseCommandText({ + text, + commandPath: input.commandPath, + pluginId: input.pluginId, + fallbackName: input.fallbackName, + }); + } catch { + return undefined; + } +} + +export function expandCommandArguments(body: string, args: string): string { + const replaced = body.replaceAll('$ARGUMENTS', args); + if (!body.includes('$ARGUMENTS') && args.length > 0) { + return `${replaced}\n\nARGUMENTS: ${args}`; + } + return replaced; +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined; +} + +function descriptionFromBody(body: string): string { + const firstLine = body + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.length > 0); + if (firstLine === undefined) return 'No description provided.'; + return firstLine.length > 240 ? `${firstLine.slice(0, 239)}…` : firstLine; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/agent-core-v2/src/app/plugin/errors.ts b/packages/agent-core-v2/src/app/plugin/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..b8d2f7ca3c3997a362194984e72b92483c67f753 --- /dev/null +++ b/packages/agent-core-v2/src/app/plugin/errors.ts @@ -0,0 +1,10 @@ +import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; + +export const PluginErrors = { + codes: { + PLUGIN_NOT_FOUND: 'plugin.not_found', + PLUGIN_LOAD_FAILED: 'plugin.load_failed', + }, +} as const satisfies ErrorDomain; + +registerErrorDomain(PluginErrors); diff --git a/packages/agent-core-v2/src/app/plugin/github-resolver.ts b/packages/agent-core-v2/src/app/plugin/github-resolver.ts new file mode 100644 index 0000000000000000000000000000000000000000..c3cca449080e60eb36e175838d98f357dd32ba7a --- /dev/null +++ b/packages/agent-core-v2/src/app/plugin/github-resolver.ts @@ -0,0 +1,137 @@ +import { Error2, ErrorCodes } from '#/errors'; + +import type { GithubRef } from './source'; +import type { PluginGithubRef } from './types'; + +export interface GithubSourceInput { + readonly kind: 'github'; + readonly owner: string; + readonly repo: string; + readonly ref?: GithubRef; +} + +export interface GithubSourceResolution { + readonly tarballUrl: string; + readonly displayVersion: string; + readonly ref: PluginGithubRef; +} + +export async function resolveGithubCommitSha( + owner: string, + repo: string, + ref: string, +): Promise { + const encodedRef = encodeCodeloadRefPath(ref); + const url = `https://github.com/${owner}/${repo}/commits/${encodedRef}.atom`; + const resp = await fetch(url, { + headers: { accept: 'application/atom+xml', range: 'bytes=0-4095' }, + signal: AbortSignal.timeout(10_000), + }); + if (!resp.ok) { + throw new Error2( + ErrorCodes.PLUGIN_LOAD_FAILED, + `Could not resolve ${owner}/${repo}@${ref}: HTTP ${resp.status} ${resp.statusText}.`, + { details: { owner, repo, ref, status: resp.status } }, + ); + } + const feed = await resp.text(); + const sha = /Grit::Commit\/([0-9a-f]{40})/i.exec(feed)?.[1]; + if (sha === undefined) { + throw new Error2( + ErrorCodes.PLUGIN_LOAD_FAILED, + `Could not resolve ${owner}/${repo}@${ref} to a commit SHA.`, + { details: { owner, repo, ref } }, + ); + } + return sha.toLowerCase(); +} + +export async function resolveGithubSource( + input: GithubSourceInput, +): Promise { + const { owner, repo } = input; + + if (input.ref !== undefined) { + return { + tarballUrl: codeloadUrl(owner, repo, input.ref), + displayVersion: input.ref.value, + ref: { kind: input.ref.kind, value: input.ref.value }, + }; + } + + const latestTag = await tryResolveLatestReleaseTag(owner, repo); + if (latestTag !== undefined) { + return { + tarballUrl: codeloadUrl(owner, repo, { kind: 'tag', value: latestTag }), + displayVersion: latestTag, + ref: { kind: 'tag', value: latestTag }, + }; + } + + const headProbe = await fetch(`https://codeload.github.com/${owner}/${repo}/zip/HEAD`, { + method: 'HEAD', + signal: AbortSignal.timeout(10_000), + }); + if (headProbe.status === 404) { + throw new Error2( + ErrorCodes.PLUGIN_LOAD_FAILED, + `Repository \`${owner}/${repo}\` not found or not accessible.`, + { details: { owner, repo } }, + ); + } + if (!headProbe.ok) { + throw new Error2( + ErrorCodes.PLUGIN_LOAD_FAILED, + `Could not access \`${owner}/${repo}\`: HTTP ${headProbe.status} ${headProbe.statusText}.`, + { details: { owner, repo, status: headProbe.status } }, + ); + } + return { + tarballUrl: `https://codeload.github.com/${owner}/${repo}/zip/HEAD`, + displayVersion: 'HEAD', + ref: { kind: 'branch', value: 'HEAD' }, + }; +} + +async function tryResolveLatestReleaseTag(owner: string, repo: string): Promise { + const url = `https://github.com/${owner}/${repo}/releases/latest`; + const resp = await fetch(url, { + redirect: 'manual', + signal: AbortSignal.timeout(10_000), + }); + + if (resp.status === 404) return undefined; + + if (resp.status !== 301 && resp.status !== 302) { + throw new Error2( + ErrorCodes.PLUGIN_LOAD_FAILED, + `Could not look up latest release of \`${owner}/${repo}\`: ` + + `HTTP ${resp.status} ${resp.statusText} (${url}). ` + + `Pin a specific ref with \`/tree/\` to bypass release lookup.`, + { details: { owner, repo, status: resp.status, url } }, + ); + } + + const location = resp.headers.get('location'); + if (location === null) return undefined; + + const match = /\/releases\/tag\/([^/?#]+)/.exec(location); + if (match === null) return undefined; + try { + return decodeURIComponent(match[1]!); + } catch { + return match[1]; + } +} + +function codeloadUrl(owner: string, repo: string, ref: GithubRef): string { + const base = `https://codeload.github.com/${owner}/${repo}/zip`; + const encoded = encodeCodeloadRefPath(ref.value); + if (ref.kind === 'sha') return `${base}/${encoded}`; + if (ref.kind === 'tag') return `${base}/refs/tags/${encoded}`; + return `${base}/${encoded}`; +} + +function encodeCodeloadRefPath(value: string): string { + return value.split('/').map(encodeURIComponent).join('/'); +} diff --git a/packages/agent-core-v2/src/app/plugin/manager.ts b/packages/agent-core-v2/src/app/plugin/manager.ts new file mode 100644 index 0000000000000000000000000000000000000000..f917017e1524964566dc0404f4aafa26fa6b137d --- /dev/null +++ b/packages/agent-core-v2/src/app/plugin/manager.ts @@ -0,0 +1,758 @@ +import { cp, mkdir, mkdtemp, realpath, rename, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import type { HookDef } from '#/features/externalHooks/internal/types'; +import { discoverFileSkills } from '#/features/skill/catalog/fileSkillDiscovery'; +import type { SkillDiscoveryResult } from '#/features/skill/catalog/skillDiscovery'; +import type { SkillRoot } from '#/features/skill/catalog/types'; +import { BugIndicatingError, Error2, ErrorCodes, PluginErrors } from '#/errors'; +import type { McpServerConfig } from '#/mcpCore/config-schema'; + +import { downloadZip, extractZip } from './archive'; +import { loadPluginCommand } from './commands'; +import { resolveGithubCommitSha, resolveGithubSource } from './github-resolver'; +import { parseManifest, type ParsedManifestResult } from './manifest'; +import { resolveInstallSource } from './source'; +import { readInstalled, writeInstalled, type InstalledRecord } from './store'; +import type { PluginAgentRoot } from './types'; +import { + normalizePluginId, + type EnabledPluginSessionStart, + type EnabledPluginSystemPrompt, + type PluginCapabilityState, + type PluginCommandDef, + type PluginGithubMetadata, + type PluginInfo, + type PluginMcpServerEntry, + type PluginMcpServerInfo, + type PluginRecord, + type PluginSource, + type PluginSummary, + type PluginUpdateStatus, + type ReloadSummary, +} from './types'; + +export interface PluginManagerOptions { + readonly kimiHomeDir: string; + readonly discoverSkills?: (roots: readonly SkillRoot[]) => Promise; +} + +interface ManagedPluginCopy { + readonly root: string; + readonly previousRoot?: string; +} + +export class PluginManager { + private readonly kimiHomeDir: string; + private readonly discoverSkills: (roots: readonly SkillRoot[]) => Promise; + private records = new Map(); + + constructor(options: PluginManagerOptions) { + this.kimiHomeDir = options.kimiHomeDir; + this.discoverSkills = options.discoverSkills ?? discoverFileSkills; + } + + async load(): Promise { + const file = await readInstalled(this.kimiHomeDir); + const next = new Map(); + for (const entry of file.plugins) { + next.set(entry.id, await this.materialize(entry)); + } + this.records = next; + } + + list(): readonly PluginRecord[] { + return [...this.records.values()].toSorted((a, b) => a.id.localeCompare(b.id)); + } + + get(id: string): PluginRecord | undefined { + return this.records.get(normalizePluginId(id)); + } + + async install(source: string): Promise { + const resolved = resolveInstallSource(source); + + let sourceRoot: string; + let originalSource: string; + let sourceType: PluginSource; + let zipTmpDir: string | undefined; + let managedCopy: ManagedPluginCopy | undefined; + let github: PluginGithubMetadata | undefined; + + try { + if (resolved.kind === 'local-path') { + sourceRoot = await normalizeInstallRoot(resolved.path); + originalSource = resolved.path; + sourceType = 'local-path'; + } else { + originalSource = source.trim(); + sourceType = resolved.kind === 'github' ? 'github' : 'zip-url'; + const zipUrl = + resolved.kind === 'github' + ? await (async () => { + const resolution = await resolveGithubSource(resolved); + const installedSha = await installedGithubSha( + resolved.owner, + resolved.repo, + resolution.ref, + ); + github = { + owner: resolved.owner, + repo: resolved.repo, + ref: resolution.ref, + installedSha, + }; + if (installedSha !== undefined) { + return `https://codeload.github.com/${resolved.owner}/${resolved.repo}/zip/${installedSha}`; + } + return resolution.tarballUrl; + })() + : resolved.path; + const buffer = await downloadZip(zipUrl); + zipTmpDir = await mkdtemp(path.join(tmpdir(), 'kimi-plugin-zip-')); + sourceRoot = await extractZip(buffer, zipTmpDir); + } + + const parsed = await parseManifest(sourceRoot); + if (parsed.manifest === undefined) { + const msg = + parsed.diagnostics.find((d) => d.severity === 'error')?.message ?? 'no manifest'; + throw new Error2( + ErrorCodes.PLUGIN_LOAD_FAILED, + sourceType === 'local-path' + ? `Cannot install plugin at ${sourceRoot}: ${msg}` + : `Cannot install plugin from ${originalSource}: ${msg}`, + { details: { sourceType } }, + ); + } + + const id = normalizePluginId(parsed.manifest.name); + managedCopy = await copyPluginToManagedRoot(this.kimiHomeDir, id, sourceRoot); + const normalizedRoot = managedCopy.root; + const managedParsed = await parseManifest(normalizedRoot); + const existing = this.records.get(id); + const now = new Date().toISOString(); + const record = await recordFrom({ + id, + root: normalizedRoot, + enabled: existing?.enabled ?? true, + installedAt: existing?.installedAt ?? now, + updatedAt: now, + originalSource, + source: sourceType, + capabilities: existing?.capabilities, + github, + parsed: managedParsed, + discoverSkills: this.discoverSkills, + }); + const next = new Map(this.records); + next.set(id, record); + await this.persist(next); + this.records = next; + if (managedCopy.previousRoot !== undefined) { + await rm(managedCopy.previousRoot, { recursive: true, force: true }).catch(() => undefined); + } + managedCopy = undefined; + return record; + } catch (error) { + if (managedCopy !== undefined) { + try { + await rollbackManagedPluginCopy(managedCopy); + } catch (rollbackError) { + throw new Error2( + ErrorCodes.PLUGIN_LOAD_FAILED, + 'Plugin installation failed and the previous managed copy could not be restored', + { + cause: new AggregateError( + [error, rollbackError], + 'Plugin installation failed and the previous managed copy could not be restored', + { cause: error }, + ), + }, + ); + } + } + throw error; + } finally { + if (zipTmpDir !== undefined) { + await rm(zipTmpDir, { recursive: true, force: true }); + } + } + } + + async setEnabled(id: string, enabled: boolean): Promise { + const key = normalizePluginId(id); + const current = this.records.get(key); + if (current === undefined) throw pluginNotFound(id); + if (current.enabled === enabled) return; + const next = new Map(this.records); + next.set(key, { ...current, enabled, updatedAt: new Date().toISOString() }); + await this.persist(next); + this.records = next; + } + + async setMcpServerEnabled(id: string, server: string, enabled: boolean): Promise { + const key = normalizePluginId(id); + const current = this.records.get(key); + if (current === undefined) throw pluginNotFound(id); + if (current.manifest?.mcpServers?.[server] === undefined) { + throw new Error2( + ErrorCodes.MCP_SERVER_NOT_FOUND, + `Plugin "${id}" does not declare MCP server "${server}"`, + { details: { id, server } }, + ); + } + const currentMcpServers = current.capabilities?.mcpServers ?? {}; + const nextCapabilities: PluginCapabilityState = { + ...current.capabilities, + mcpServers: { + ...currentMcpServers, + [server]: { enabled }, + }, + }; + const next = new Map(this.records); + next.set(key, { + ...current, + capabilities: nextCapabilities, + updatedAt: new Date().toISOString(), + }); + await this.persist(next); + this.records = next; + } + + async remove(id: string): Promise { + const key = normalizePluginId(id); + const next = new Map(this.records); + if (!next.delete(key)) { + throw pluginNotFound(id); + } + await this.persist(next); + this.records = next; + } + + async checkUpdates(): Promise { + const records = [...this.records.values()].filter( + (record) => record.source === 'github' && record.github !== undefined, + ); + const results = await Promise.all( + records.map(async (record) => { + try { + return await checkGithubUpdate(record); + } catch { + return undefined; + } + }), + ); + return results + .filter((result): result is PluginUpdateStatus => result !== undefined) + .toSorted((a, b) => a.id.localeCompare(b.id)); + } + + async reload(): Promise { + const prevIds = new Set(this.records.keys()); + const file = await readInstalled(this.kimiHomeDir); + const next = new Map(); + const errors: Array<{ id: string; message: string }> = []; + for (const entry of file.plugins) { + try { + next.set(entry.id, await this.materialize(entry)); + } catch (error) { + errors.push({ id: entry.id, message: (error as Error).message }); + } + } + const added: string[] = []; + for (const id of next.keys()) if (!prevIds.has(id)) added.push(id); + const removed: string[] = []; + for (const id of prevIds) if (!next.has(id)) removed.push(id); + this.records = next; + return { added, removed, errors }; + } + + enabledHooks(): readonly HookDef[] { + const out: HookDef[] = []; + for (const record of this.records.values()) { + if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue; + for (const hook of record.manifest.hooks ?? []) { + out.push({ + ...hook, + cwd: record.root, + env: { + KIMI_CODE_HOME: this.kimiHomeDir, + KIMI_PLUGIN_ROOT: record.root, + }, + }); + } + } + return out; + } + + async enabledCommands(): Promise { + const out: PluginCommandDef[] = []; + const records = [...this.records.values()]; + for (const record of records) { + if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue; + for (const entry of record.manifest.commands ?? []) { + const def = await loadPluginCommand({ + commandPath: entry.path, + pluginId: record.id, + fallbackName: entry.name, + }); + if (def !== undefined) out.push(def); + } + } + return out; + } + + pluginSkillRoots(): readonly SkillRoot[] { + const roots: SkillRoot[] = []; + for (const record of this.records.values()) { + if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue; + for (const dir of record.manifest.skills ?? []) { + roots.push({ + path: dir, + source: 'extra', + plugin: { id: record.id, instructions: record.skillInstructions }, + scanMode: record.manifest.rootSkillFallback ? 'root-skill-only' : undefined, + }); + } + } + return roots; + } + + pluginAgentRoots(): readonly PluginAgentRoot[] { + const roots: PluginAgentRoot[] = []; + for (const record of this.records.values()) { + if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue; + for (const dir of record.manifest.agents ?? []) { + roots.push({ path: dir, source: 'plugin' }); + } + } + return roots; + } + + enabledSessionStarts(): readonly EnabledPluginSessionStart[] { + const out: EnabledPluginSessionStart[] = []; + for (const record of this.records.values()) { + if (!record.enabled || record.state !== 'ok') continue; + const skill = record.manifest?.sessionStart?.skill; + if (skill === undefined) continue; + out.push({ pluginId: record.id, skillName: skill }); + } + return out; + } + + enabledSystemPrompts(): readonly EnabledPluginSystemPrompt[] { + const out: EnabledPluginSystemPrompt[] = []; + for (const record of this.records.values()) { + if (!record.enabled || record.state !== 'ok') continue; + const content = record.manifest?.systemPrompt; + if (content === undefined) continue; + out.push({ pluginId: record.id, content }); + } + return out; + } + + enabledMcpServers(): Record { + const out: Record = {}; + for (const record of this.records.values()) { + if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue; + for (const [name, config] of Object.entries(record.manifest.mcpServers ?? {})) { + if (!isMcpServerEnabled(record, name, config)) continue; + out[pluginMcpRuntimeName(record.id, name)] = withPluginMcpRuntime( + withMcpServerEnabled(config, true), + record.root, + this.kimiHomeDir, + ); + } + } + return out; + } + + mcpServerEntries(): readonly PluginMcpServerEntry[] { + const out: PluginMcpServerEntry[] = []; + for (const record of this.records.values()) { + if (record.state !== 'ok' || record.manifest === undefined) continue; + for (const [name, config] of Object.entries(record.manifest.mcpServers ?? {})) { + const enabled = record.enabled && isMcpServerEnabled(record, name, config); + const effective = withPluginMcpRuntime( + withMcpServerEnabled(config, enabled), + record.root, + this.kimiHomeDir, + ); + out.push({ + name: pluginMcpRuntimeName(record.id, name), + config: effective, + pluginId: record.id, + serverName: name, + }); + } + } + return out; + } + + summaries(): readonly PluginSummary[] { + return this.list().map((record) => recordToSummary(record)); + } + + info(id: string): PluginInfo | undefined { + const record = this.get(id); + return record === undefined ? undefined : recordToInfo(record); + } + + private async persist(records: ReadonlyMap): Promise { + const installed: InstalledRecord[] = [...records.values()].map((record) => ({ + id: record.id, + root: record.root, + source: record.source, + enabled: record.enabled, + installedAt: record.installedAt, + updatedAt: record.updatedAt, + originalSource: record.originalSource, + capabilities: record.capabilities, + github: record.github, + })); + await writeInstalled(this.kimiHomeDir, { version: 1, plugins: installed }); + } + + private async materialize(entry: InstalledRecord): Promise { + const parsed = await parseManifest(entry.root); + return recordFrom({ + id: entry.id, + root: entry.root, + enabled: entry.enabled, + installedAt: entry.installedAt, + updatedAt: entry.updatedAt, + originalSource: entry.originalSource, + capabilities: entry.capabilities, + github: entry.github, + source: entry.source, + parsed, + discoverSkills: this.discoverSkills, + }); + } +} + +async function installedGithubSha( + owner: string, + repo: string, + ref: PluginGithubMetadata['ref'], +): Promise { + if (ref.kind === 'sha' && ref.value.length === 40) return ref.value.toLowerCase(); + return resolveGithubCommitSha(owner, repo, ref.value); +} + +async function checkGithubUpdate(record: PluginRecord): Promise { + const github = record.github; + if (github === undefined) + throw new BugIndicatingError(`Plugin "${record.id}" has no GitHub metadata`); + const current = github.ref; + const pinned = explicitGithubRef(record); + + if (pinned?.kind === 'tag' || pinned?.kind === 'sha') { + return { + id: record.id, + source: 'github', + current, + latest: current, + displayVersion: current.value, + updateAvailable: false, + }; + } + + if (pinned?.kind === 'branch') { + const latestSha = await resolveGithubCommitSha(github.owner, github.repo, pinned.value); + return { + id: record.id, + source: 'github', + current, + latest: current, + displayVersion: latestSha.slice(0, 12), + updateAvailable: github.installedSha === undefined || github.installedSha !== latestSha, + }; + } + + const latest = await resolveGithubSource({ + kind: 'github', + owner: github.owner, + repo: github.repo, + }); + let updateAvailable = current.kind !== latest.ref.kind || current.value !== latest.ref.value; + if (!updateAvailable && (latest.ref.kind === 'branch' || latest.ref.kind === 'tag')) { + const latestSha = await resolveGithubCommitSha(github.owner, github.repo, latest.ref.value); + updateAvailable = github.installedSha === undefined || github.installedSha !== latestSha; + } + return { + id: record.id, + source: 'github', + current, + latest: latest.ref, + displayVersion: latest.displayVersion, + updateAvailable, + }; +} + +function explicitGithubRef(record: PluginRecord): PluginGithubMetadata['ref'] | undefined { + const fallback = + record.github?.ref.kind === 'sha' || + (record.github?.ref.kind === 'branch' && record.github.ref.value !== 'HEAD') + ? record.github.ref + : undefined; + if (record.originalSource === undefined) return fallback; + try { + const source = resolveInstallSource(record.originalSource); + return source.kind === 'github' ? source.ref : fallback; + } catch { + return fallback; + } +} + +function pluginNotFound(id: string): Error2 { + return new Error2(PluginErrors.codes.PLUGIN_NOT_FOUND, `Plugin "${id}" is not installed`, { + details: { id }, + }); +} + +async function normalizeInstallRoot(rootPath: string): Promise { + const trimmed = rootPath.trim(); + if (!path.isAbsolute(trimmed)) { + throw new Error2( + ErrorCodes.VALIDATION_FAILED, + `Plugin root must be an absolute path (got "${rootPath}")`, + { details: { path: rootPath } }, + ); + } + let resolved: string; + try { + resolved = await realpath(trimmed); + } catch (error) { + throw new Error2(ErrorCodes.FS_PATH_NOT_FOUND, `Plugin root does not exist: ${trimmed}`, { + cause: error, + details: { path: trimmed }, + }); + } + if (!(await stat(resolved)).isDirectory()) { + throw new Error2(ErrorCodes.VALIDATION_FAILED, `Plugin root is not a directory: ${trimmed}`, { + details: { path: trimmed }, + }); + } + return resolved; +} + +async function copyPluginToManagedRoot( + kimiHomeDir: string, + id: string, + sourceRoot: string, +): Promise { + const managedRoot = path.join(kimiHomeDir, 'plugins', 'managed', id); + const managedDir = path.dirname(managedRoot); + await mkdir(managedDir, { recursive: true }); + const stagingRoot = await mkdtemp(path.join(managedDir, `${id}-`)); + const previousRoot = `${stagingRoot}-previous`; + let movedPreviousRoot = false; + let published = false; + try { + await cp(sourceRoot, stagingRoot, { recursive: true }); + try { + await rename(managedRoot, previousRoot); + movedPreviousRoot = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + await rename(stagingRoot, managedRoot); + published = true; + return { + root: await realpath(managedRoot), + previousRoot: movedPreviousRoot ? previousRoot : undefined, + }; + } catch (error) { + await rm(published ? managedRoot : stagingRoot, { recursive: true, force: true }); + if (movedPreviousRoot) await rename(previousRoot, managedRoot); + throw error; + } +} + +async function rollbackManagedPluginCopy(copy: ManagedPluginCopy): Promise { + await rm(copy.root, { recursive: true, force: true }); + if (copy.previousRoot !== undefined) { + await rename(copy.previousRoot, copy.root); + } +} + +async function recordFrom(input: { + id: string; + root: string; + enabled: boolean; + installedAt: string; + updatedAt?: string; + originalSource?: string; + capabilities?: PluginCapabilityState; + github?: PluginGithubMetadata; + source?: PluginSource; + parsed: ParsedManifestResult; + discoverSkills: (roots: readonly SkillRoot[]) => Promise; +}): Promise { + const { parsed } = input; + const hasError = parsed.diagnostics.some((d) => d.severity === 'error'); + return { + id: input.id, + root: input.root, + source: input.source ?? 'local-path', + enabled: input.enabled, + state: hasError || parsed.manifest === undefined ? 'error' : 'ok', + installedAt: input.installedAt, + updatedAt: input.updatedAt, + originalSource: input.originalSource, + capabilities: input.capabilities, + github: input.github, + skillCount: await countDiscoveredPluginSkills(input.id, parsed.manifest, input.discoverSkills), + manifest: parsed.manifest, + manifestKind: parsed.manifestKind, + manifestPath: parsed.manifestPath, + shadowedManifestPath: parsed.shadowedManifestPath, + diagnostics: parsed.diagnostics, + skillInstructions: parsed.manifest?.skillInstructions, + }; +} + +function recordToSummary(record: PluginRecord): PluginSummary { + return { + id: record.id, + displayName: record.manifest?.interface?.displayName ?? record.id, + version: record.manifest?.version, + enabled: record.enabled, + state: record.state, + skillCount: record.skillCount, + mcpServerCount: Object.keys(record.manifest?.mcpServers ?? {}).length, + enabledMcpServerCount: pluginMcpServersInfo(record).filter((server) => server.enabled).length, + hookCount: record.manifest?.hooks?.length ?? 0, + commandCount: record.manifest?.commands?.length ?? 0, + hasErrors: record.diagnostics.some((d) => d.severity === 'error'), + source: record.source, + originalSource: record.originalSource, + github: record.github, + }; +} + +function recordToInfo(record: PluginRecord): PluginInfo { + return { + ...recordToSummary(record), + root: record.root, + installedAt: record.installedAt, + updatedAt: record.updatedAt, + manifestKind: record.manifestKind, + manifestPath: record.manifestPath, + manifest: record.manifest, + mcpServers: pluginMcpServersInfo(record), + shadowedManifestPath: record.shadowedManifestPath, + diagnostics: record.diagnostics, + }; +} + +function isMcpServerEnabled(record: PluginRecord, name: string, config: McpServerConfig): boolean { + return record.capabilities?.mcpServers?.[name]?.enabled ?? config.enabled !== false; +} + +function pluginMcpServersInfo(record: PluginRecord): readonly PluginMcpServerInfo[] { + return Object.entries(record.manifest?.mcpServers ?? {}) + .map(([name, config]) => pluginMcpServerInfo(record, name, config)) + .toSorted((a, b) => a.name.localeCompare(b.name)); +} + +function pluginMcpServerInfo( + record: PluginRecord, + name: string, + config: McpServerConfig, +): PluginMcpServerInfo { + if (config.transport === 'http' || config.transport === 'sse') { + return { + name, + runtimeName: pluginMcpRuntimeName(record.id, name), + enabled: isMcpServerEnabled(record, name, config), + transport: config.transport, + url: config.url, + headerKeys: config.headers === undefined ? undefined : Object.keys(config.headers).toSorted(), + }; + } + return { + name, + runtimeName: pluginMcpRuntimeName(record.id, name), + enabled: isMcpServerEnabled(record, name, config), + transport: 'stdio', + command: config.command, + args: config.args, + cwd: config.cwd, + envKeys: config.env === undefined ? undefined : Object.keys(config.env).toSorted(), + }; +} + +function pluginMcpRuntimeName(pluginId: string, serverName: string): string { + return `plugin-${pluginId}:${serverName}`; +} + +const KIMI_NODE_FALLBACK_SUBCOMMAND = '__plugin_run_node'; + +function withMcpServerEnabled(config: McpServerConfig, enabled: boolean): McpServerConfig { + return { ...config, enabled }; +} + +function withPluginMcpRuntime( + config: McpServerConfig, + pluginRoot: string, + kimiHomeDir: string, +): McpServerConfig { + if (config.transport === 'http' || config.transport === 'sse') return config; + + const env = { + ...config.env, + KIMI_CODE_HOME: kimiHomeDir, + KIMI_PLUGIN_ROOT: pluginRoot, + }; + + if (config.command === 'node' && isElectron()) { + return { + ...config, + command: process.execPath, + args: config.args ?? [], + cwd: config.cwd ?? pluginRoot, + env: { ...env, ELECTRON_RUN_AS_NODE: '1' }, + }; + } + + if (config.command === 'node' && isKimiNativeBinary()) { + return { + ...config, + command: process.execPath, + args: [KIMI_NODE_FALLBACK_SUBCOMMAND, ...(config.args ?? [])], + cwd: config.cwd ?? pluginRoot, + env, + }; + } + + return { ...config, cwd: config.cwd ?? pluginRoot, env }; +} + +function isElectron(): boolean { + return typeof process.versions['electron'] === 'string'; +} + +function isKimiNativeBinary(): boolean { + return !path.basename(process.execPath).toLowerCase().startsWith('node'); +} + +async function countDiscoveredPluginSkills( + pluginId: string, + manifest: PluginRecord['manifest'], + discoverSkills: (roots: readonly SkillRoot[]) => Promise, +): Promise { + const dirs = manifest?.skills ?? []; + if (dirs.length === 0) return 0; + const roots: SkillRoot[] = dirs.map((dir) => ({ + path: dir, + source: 'extra', + plugin: { id: pluginId, instructions: manifest?.skillInstructions }, + scanMode: manifest?.rootSkillFallback ? 'root-skill-only' : undefined, + })); + const result = await discoverSkills(roots); + return result.skills.length; +} diff --git a/packages/agent-core-v2/src/app/plugin/manifest.ts b/packages/agent-core-v2/src/app/plugin/manifest.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc5ca205a7ea6c1e465b83e999e695db53167e63 --- /dev/null +++ b/packages/agent-core-v2/src/app/plugin/manifest.ts @@ -0,0 +1,571 @@ +import { readdir, readFile, realpath, stat } from 'node:fs/promises'; +import path from 'node:path'; + +import { HookDefSchema, type HookDefConfig } from '#/features/externalHooks/configSection'; +import { McpServerConfigSchema, type McpServerConfig } from '#/mcpCore/config-schema'; + +import { + PLUGIN_NAME_REGEX, + type PluginCommandEntry, + type PluginDiagnostic, + type PluginInterface, + type PluginManifest, + type PluginManifestKind, +} from './types'; + +const KIMI_PLUGIN_ROOT_PATH = 'kimi.plugin.json'; +const KIMI_PLUGIN_DIR_PATH = '.kimi-plugin/plugin.json'; + +export const PLUGIN_SYSTEM_PROMPT_MAX_BYTES = 32 * 1024; + +const UNSUPPORTED_RUNTIME_FIELDS = [ + 'tools', + 'apps', + 'inject', + 'configFile', + 'config_file', + 'bootstrap', +] as const; + +export interface ParsedManifestResult { + readonly manifest?: PluginManifest; + readonly manifestKind?: PluginManifestKind; + readonly manifestPath?: string; + readonly shadowedManifestPath?: string; + readonly diagnostics: readonly PluginDiagnostic[]; +} + +export async function parseManifest(pluginRoot: string): Promise { + const rootJsonPath = path.join(pluginRoot, KIMI_PLUGIN_ROOT_PATH); + const dirJsonPath = path.join(pluginRoot, KIMI_PLUGIN_DIR_PATH); + const rootJsonExists = await isFile(rootJsonPath); + const dirJsonExists = await isFile(dirJsonPath); + + if (!rootJsonExists && !dirJsonExists) { + return { + diagnostics: [ + { + severity: 'error', + message: `No manifest at ${KIMI_PLUGIN_ROOT_PATH} or ${KIMI_PLUGIN_DIR_PATH}`, + }, + ], + }; + } + + const manifestPath = rootJsonExists ? rootJsonPath : dirJsonPath; + const manifestKind: PluginManifestKind = rootJsonExists ? 'kimi-plugin-root' : 'kimi-plugin-dir'; + const shadowedManifestPath = rootJsonExists && dirJsonExists ? dirJsonPath : undefined; + + let raw: unknown; + try { + raw = JSON.parse(await readFile(manifestPath, 'utf8')); + } catch (error) { + return { + manifestKind, + manifestPath, + shadowedManifestPath, + diagnostics: [ + { + severity: 'error', + message: `Failed to parse ${path.relative(pluginRoot, manifestPath)}: ${(error as Error).message}`, + }, + ], + }; + } + + if (!isObject(raw)) { + return { + manifestKind, + manifestPath, + shadowedManifestPath, + diagnostics: [{ severity: 'error', message: 'manifest must be a JSON object' }], + }; + } + + const diagnostics: PluginDiagnostic[] = []; + + const name = typeof raw['name'] === 'string' ? raw['name'].trim() : ''; + if (name.length === 0) { + diagnostics.push({ severity: 'error', message: '"name" is required' }); + return { manifestKind, manifestPath, shadowedManifestPath, diagnostics }; + } + if (!PLUGIN_NAME_REGEX.test(name)) { + diagnostics.push({ + severity: 'error', + message: `"name" must match ${PLUGIN_NAME_REGEX} (got "${name}")`, + }); + return { manifestKind, manifestPath, shadowedManifestPath, diagnostics }; + } + + let skills = await resolveDirListField(pluginRoot, 'skills', raw['skills'], diagnostics); + let rootSkillFallback: boolean | undefined; + if (raw['skills'] === undefined) { + const rootSkillMd = path.join(pluginRoot, 'SKILL.md'); + if (await isFile(rootSkillMd)) { + skills = [pluginRoot]; + rootSkillFallback = true; + } + } + + let agents = await resolveDirListField(pluginRoot, 'agents', raw['agents'], diagnostics); + if (raw['agents'] === undefined) { + const agentsDir = path.join(pluginRoot, 'agents'); + if (await isDir(agentsDir)) { + agents = [agentsDir]; + } + } + + const skillInstructions = + typeof raw['skillInstructions'] === 'string' ? raw['skillInstructions'] : undefined; + + const systemPrompt = await readSystemPrompt(pluginRoot, raw, diagnostics); + + recordUnsupportedRuntimeFields(raw, diagnostics); + + const manifest: PluginManifest = { + name, + version: stringField(raw, 'version'), + description: stringField(raw, 'description'), + keywords: stringArrayField(raw, 'keywords'), + homepage: stringField(raw, 'homepage'), + license: stringField(raw, 'license'), + author: readAuthor(raw['author']), + skills, + rootSkillFallback, + agents, + sessionStart: readSessionStart(raw['sessionStart'], diagnostics), + mcpServers: await readMcpServers(pluginRoot, raw['mcpServers'], diagnostics), + hooks: readHooks(raw['hooks'], diagnostics), + commands: await readCommands(pluginRoot, raw['commands'], diagnostics), + interface: readInterface(raw['interface']), + skillInstructions, + systemPrompt, + }; + + return { manifest, manifestKind, manifestPath, shadowedManifestPath, diagnostics }; +} + +function recordUnsupportedRuntimeFields( + raw: Record, + diagnostics: PluginDiagnostic[], +): void { + for (const field of UNSUPPORTED_RUNTIME_FIELDS) { + if (raw[field] === undefined) continue; + diagnostics.push({ + severity: 'info', + message: `"${field}" is present but not supported by Kimi plugins`, + }); + } +} + +async function resolveDirListField( + pluginRoot: string, + field: string, + raw: unknown, + diagnostics: PluginDiagnostic[], +): Promise { + if (raw === undefined) return []; + const entries: string[] = []; + if (typeof raw === 'string') { + entries.push(raw); + } else if (Array.isArray(raw) && raw.every((entry) => typeof entry === 'string')) { + entries.push(...raw); + } else { + diagnostics.push({ severity: 'error', message: `"${field}" must be a string or string[]` }); + return []; + } + + const resolved: string[] = []; + for (const entry of entries) { + if (!entry.startsWith('./')) { + diagnostics.push({ + severity: 'error', + message: `"${field}" path must start with "./" (got "${entry}")`, + }); + continue; + } + const absolute = path.resolve(pluginRoot, entry); + let real: string; + try { + real = await realpath(absolute); + } catch { + real = absolute; + } + const rootReal = await realpath(pluginRoot).catch(() => pluginRoot); + if (!isWithin(real, rootReal)) { + diagnostics.push({ + severity: 'error', + message: `"${field}" path resolves outside the plugin (${entry})`, + }); + continue; + } + if (!(await isDir(real))) { + diagnostics.push({ + severity: 'warn', + message: `"${field}" path is not a directory (${entry})`, + }); + continue; + } + resolved.push(real); + } + return resolved; +} + +async function resolvePluginPathField(input: { + readonly pluginRoot: string; + readonly field: string; + readonly value: string; + readonly diagnostics: PluginDiagnostic[]; +}): Promise { + if (!input.value.startsWith('./')) { + input.diagnostics.push({ + severity: 'warn', + message: `"${input.field}" path must start with "./" (got "${input.value}")`, + }); + return undefined; + } + const absolute = path.resolve(input.pluginRoot, input.value); + let real: string; + try { + real = await realpath(absolute); + } catch { + real = absolute; + } + const rootReal = await realpath(input.pluginRoot).catch(() => input.pluginRoot); + if (!isWithin(real, rootReal)) { + input.diagnostics.push({ + severity: 'warn', + message: `"${input.field}" path resolves outside the plugin (${input.value})`, + }); + return undefined; + } + return real; +} + +function readSessionStart( + raw: unknown, + diagnostics: PluginDiagnostic[], +): PluginManifest['sessionStart'] { + if (raw === undefined) return undefined; + if (!isObject(raw)) { + diagnostics.push({ severity: 'warn', message: '"sessionStart" must be an object' }); + return undefined; + } + const skill = typeof raw['skill'] === 'string' ? raw['skill'].trim() : ''; + if (skill.length === 0) { + diagnostics.push({ + severity: 'warn', + message: '"sessionStart.skill" is required when sessionStart is present', + }); + return undefined; + } + return { skill }; +} + +async function readSystemPrompt( + pluginRoot: string, + raw: Record, + diagnostics: PluginDiagnostic[], +): Promise { + const parts: string[] = []; + if (raw['systemPrompt'] !== undefined && typeof raw['systemPrompt'] !== 'string') { + diagnostics.push({ severity: 'warn', message: '"systemPrompt" must be a string' }); + } + const inline = stringField(raw, 'systemPrompt'); + if (inline !== undefined) { + const inlineBytes = Buffer.byteLength(inline, 'utf8'); + if (inlineBytes > PLUGIN_SYSTEM_PROMPT_MAX_BYTES) { + diagnostics.push({ + severity: 'warn', + message: + `"systemPrompt" is ${inlineBytes} bytes, exceeding the ` + + `${PLUGIN_SYSTEM_PROMPT_MAX_BYTES / 1024} KB limit; the field is ignored`, + }); + } else { + parts.push(inline); + } + } + + const pathValue = raw['systemPromptPath']; + if (pathValue !== undefined) { + if (typeof pathValue !== 'string') { + diagnostics.push({ severity: 'warn', message: '"systemPromptPath" must be a string' }); + } else if (pathValue.trim().length === 0) { + diagnostics.push({ severity: 'warn', message: '"systemPromptPath" must not be blank' }); + } else { + const resolved = await resolvePluginPathField({ + pluginRoot, + field: 'systemPromptPath', + value: pathValue.trim(), + diagnostics, + }); + if (resolved !== undefined) { + const fileStat = await stat(resolved).catch(() => undefined); + if (fileStat === undefined || !fileStat.isFile()) { + diagnostics.push({ + severity: 'warn', + message: `"systemPromptPath" is not a file (${pathValue})`, + }); + } else if (fileStat.size > PLUGIN_SYSTEM_PROMPT_MAX_BYTES) { + diagnostics.push({ + severity: 'warn', + message: + `"systemPromptPath" is ${fileStat.size} bytes, exceeding the ` + + `${PLUGIN_SYSTEM_PROMPT_MAX_BYTES / 1024} KB limit; the file is ignored (${pathValue})`, + }); + } else { + try { + const content = (await readFile(resolved, 'utf8')).replace(/^\uFEFF/, '').trim(); + if (content.length > 0) parts.push(content); + } catch (error) { + diagnostics.push({ + severity: 'warn', + message: `Failed to read "systemPromptPath" (${pathValue}): ${(error as Error).message}`, + }); + } + } + } + } + } + + return parts.length === 0 ? undefined : parts.join('\n\n'); +} + +async function readMcpServers( + pluginRoot: string, + raw: unknown, + diagnostics: PluginDiagnostic[], +): Promise { + if (raw === undefined) return undefined; + if (!isObject(raw)) { + diagnostics.push({ severity: 'warn', message: '"mcpServers" must be an object' }); + return undefined; + } + + const out: Record = {}; + for (const [name, value] of Object.entries(raw)) { + const trimmedName = name.trim(); + if (trimmedName.length === 0) { + diagnostics.push({ + severity: 'warn', + message: '"mcpServers" entries must have a non-empty name', + }); + continue; + } + const parsed = McpServerConfigSchema.safeParse(value); + if (!parsed.success) { + diagnostics.push({ + severity: 'warn', + message: `Invalid MCP server "${trimmedName}": ${parsed.error.message}`, + }); + continue; + } + const normalized = await normalizePluginMcpServer({ + pluginRoot, + name: trimmedName, + config: parsed.data, + diagnostics, + }); + if (normalized !== undefined) out[trimmedName] = normalized; + } + return Object.keys(out).length === 0 ? undefined : out; +} + +function readHooks( + raw: unknown, + diagnostics: PluginDiagnostic[], +): readonly HookDefConfig[] | undefined { + if (raw === undefined) return undefined; + if (!Array.isArray(raw)) { + diagnostics.push({ severity: 'warn', message: '"hooks" must be an array' }); + return undefined; + } + const out: HookDefConfig[] = []; + raw.forEach((entry, i) => { + const parsed = HookDefSchema.safeParse(entry); + if (!parsed.success) { + diagnostics.push({ + severity: 'warn', + message: `Invalid hook at index ${i}: ${parsed.error.message}`, + }); + } else { + out.push(parsed.data); + } + }); + return out.length === 0 ? undefined : out; +} + +async function readCommands( + pluginRoot: string, + raw: unknown, + diagnostics: PluginDiagnostic[], +): Promise { + if (raw === undefined) return undefined; + const entries: string[] = []; + if (typeof raw === 'string') { + entries.push(raw); + } else if (Array.isArray(raw) && raw.every((entry) => typeof entry === 'string')) { + entries.push(...raw); + } else { + diagnostics.push({ severity: 'warn', message: '"commands" must be a string or string[]' }); + return undefined; + } + + const files: PluginCommandEntry[] = []; + for (const entry of entries) { + const resolved = await resolvePluginPathField({ + pluginRoot, + field: 'commands', + value: entry, + diagnostics, + }); + if (resolved === undefined) continue; + if (await isDir(resolved)) { + files.push(...(await listMarkdownFilesRecursive(resolved))); + } else if ((await isFile(resolved)) && resolved.endsWith('.md')) { + files.push({ path: resolved, name: commandNameFromFile(resolved, path.dirname(resolved)) }); + } else { + diagnostics.push({ + severity: 'warn', + message: `"commands" entry must be a directory or .md file (${entry})`, + }); + } + } + return files.length === 0 ? undefined : files.toSorted((a, b) => a.name.localeCompare(b.name)); +} + +async function listMarkdownFilesRecursive(root: string): Promise { + const out: PluginCommandEntry[] = []; + await walkMarkdown(root, root, out); + return out; +} + +async function walkMarkdown( + root: string, + dir: string, + out: PluginCommandEntry[], +): Promise { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + await walkMarkdown(root, full, out); + } else if (entry.isFile() && entry.name.endsWith('.md')) { + out.push({ path: full, name: commandNameFromFile(full, root) }); + } + } +} + +function commandNameFromFile(file: string, root: string): string { + const relative = path.relative(root, file).replace(/\.md$/i, ''); + return relative.split(path.sep).join('/'); +} + +async function normalizePluginMcpServer(input: { + readonly pluginRoot: string; + readonly name: string; + readonly config: McpServerConfig; + readonly diagnostics: PluginDiagnostic[]; +}): Promise { + const { config } = input; + if (config.transport === 'http' || config.transport === 'sse') return config; + + let command = config.command; + if (command.startsWith('./')) { + const resolvedCommand = await resolvePluginPathField({ + pluginRoot: input.pluginRoot, + field: `mcpServers.${input.name}.command`, + value: command, + diagnostics: input.diagnostics, + }); + if (resolvedCommand === undefined) return undefined; + command = resolvedCommand; + } else if (command.includes('/') || path.isAbsolute(command)) { + input.diagnostics.push({ + severity: 'warn', + message: `"mcpServers.${input.name}.command" must be a PATH command or start with "./"`, + }); + return undefined; + } + + let cwd = config.cwd; + if (cwd !== undefined) { + const resolvedCwd = await resolvePluginPathField({ + pluginRoot: input.pluginRoot, + field: `mcpServers.${input.name}.cwd`, + value: cwd, + diagnostics: input.diagnostics, + }); + if (resolvedCwd === undefined) return undefined; + cwd = resolvedCwd; + } + + return { ...config, command, cwd }; +} + +function readAuthor(raw: unknown): PluginManifest['author'] { + if (typeof raw === 'string') return { name: raw }; + if (!isObject(raw)) return undefined; + const name = stringField(raw, 'name'); + const email = stringField(raw, 'email'); + if (name === undefined && email === undefined) return undefined; + return { name, email }; +} + +function readInterface(raw: unknown): PluginInterface | undefined { + if (!isObject(raw)) return undefined; + const out: PluginInterface = { + displayName: stringField(raw, 'displayName'), + shortDescription: stringField(raw, 'shortDescription'), + longDescription: stringField(raw, 'longDescription'), + developerName: stringField(raw, 'developerName'), + websiteURL: stringField(raw, 'websiteURL'), + }; + const hasAny = Object.values(out).some((value) => value !== undefined); + return hasAny ? out : undefined; +} + +function stringField(raw: Record, key: string): string | undefined { + const value = raw[key]; + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length === 0 ? undefined : trimmed; +} + +function stringArrayField(raw: Record, key: string): readonly string[] | undefined { + const value = raw[key]; + if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string')) { + return undefined; + } + return value as readonly string[]; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isWithin(child: string, parent: string): boolean { + const relative = path.relative(parent, child); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + +async function isFile(p: string): Promise { + try { + return (await stat(p)).isFile(); + } catch { + return false; + } +} + +async function isDir(p: string): Promise { + try { + return (await stat(p)).isDirectory(); + } catch { + return false; + } +} diff --git a/packages/agent-core-v2/src/app/plugin/marketplace.ts b/packages/agent-core-v2/src/app/plugin/marketplace.ts new file mode 100644 index 0000000000000000000000000000000000000000..2d2af1fd835addcb9f50fca2f9a191e52e87e9f2 --- /dev/null +++ b/packages/agent-core-v2/src/app/plugin/marketplace.ts @@ -0,0 +1,365 @@ +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { gt, valid } from 'semver'; + +export const KIMI_CODE_PLUGIN_MARKETPLACE_URL = + 'https://code.kimi.com/kimi-code/plugins/marketplace.json'; +export const KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_URL'; + +export const PLUGIN_MARKETPLACE_TIERS = ['official', 'curated'] as const; + +export type PluginMarketplaceTier = (typeof PLUGIN_MARKETPLACE_TIERS)[number]; + +export interface PluginMarketplaceEntry { + readonly id: string; + readonly displayName: string; + readonly source: string; + readonly tier?: PluginMarketplaceTier; + readonly version?: string; + readonly description?: string; + readonly homepage?: string; + readonly keywords?: readonly string[]; + readonly builtIn?: boolean; +} + +export interface PluginMarketplace { + readonly source: string; + readonly version?: string; + readonly plugins: readonly PluginMarketplaceEntry[]; +} + +export type MarketplaceUpdateStatus = + | { readonly kind: 'not-installed' } + | { readonly kind: 'up-to-date'; readonly version?: string } + | { readonly kind: 'update'; readonly local: string; readonly latest: string }; + +export interface MarketplaceLocation { + readonly raw: string; + readonly kind: 'remote' | 'local'; + readonly resolved: string; +} + +export interface ReadPluginMarketplaceOptions { + readonly source: string; + readonly workDir: string; + readonly fetchImpl?: typeof fetch; + readonly sourceCheckoutLocation?: () => Promise; +} + +export function computeUpdateStatus( + latest: string | undefined, + local: string | undefined, + installed: boolean, +): MarketplaceUpdateStatus { + if (!installed) return { kind: 'not-installed' }; + if ( + latest !== undefined && + local !== undefined && + valid(latest) !== null && + valid(local) !== null && + gt(latest, local) + ) { + return { kind: 'update', local, latest }; + } + return { kind: 'up-to-date', version: local }; +} + +export function resolveMarketplaceLocation(source: string, workDir: string): MarketplaceLocation { + const trimmed = source.trim(); + if (trimmed.length === 0) { + throw new Error(`${KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV} cannot be empty.`); + } + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + return { raw: trimmed, kind: 'remote', resolved: trimmed }; + } + if (trimmed.startsWith('file://')) { + const path = fileURLToPath(trimmed); + return { raw: trimmed, kind: 'local', resolved: path }; + } + return { raw: trimmed, kind: 'local', resolved: resolveLocalPath(trimmed, workDir) }; +} + +export async function readPluginMarketplace( + options: ReadPluginMarketplaceOptions, +): Promise<{ raw: string; location: MarketplaceLocation }> { + const location = resolveMarketplaceLocation(options.source, options.workDir); + const fetchImpl = options.fetchImpl ?? fetch; + try { + return { raw: await readMarketplaceText(location, fetchImpl), location }; + } catch (error) { + const fallback = + options.sourceCheckoutLocation !== undefined + ? await options.sourceCheckoutLocation() + : undefined; + if (fallback === undefined) throw error; + return { raw: await readMarketplaceText(fallback, fetchImpl), location: fallback }; + } +} + +export function parsePluginMarketplace(raw: string, location: MarketplaceLocation): PluginMarketplace { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new Error(`Plugin marketplace is not valid JSON: ${formatParseError(error)}`, { + cause: error, + }); + } + + if (!isRecord(parsed)) { + throw new TypeError('Plugin marketplace must be an object.'); + } + const rawPlugins = parsed['plugins']; + if (!Array.isArray(rawPlugins)) { + throw new TypeError('Plugin marketplace must contain a "plugins" array.'); + } + + return { + source: location.resolved, + version: stringField(parsed, 'version'), + plugins: rawPlugins.map((entry, index) => parseMarketplaceEntry(entry, index, location)), + }; +} + +export function withBuiltInEntries( + marketplace: PluginMarketplace, + builtIns: readonly PluginMarketplaceEntry[], +): PluginMarketplace { + const builtInIds = new Set(builtIns.map((entry) => entry.id)); + const catalogById = new Map(marketplace.plugins.map((entry) => [entry.id, entry])); + const catalog = marketplace.plugins.filter((entry) => !builtInIds.has(entry.id)); + const enrichedBuiltIns = builtIns.map((entry) => { + const version = catalogById.get(entry.id)?.version; + return version === undefined ? entry : { ...entry, version }; + }); + return { ...marketplace, plugins: [...catalog, ...enrichedBuiltIns] }; +} + +export async function withLatestVersions( + marketplace: PluginMarketplace, + fetchImpl: typeof fetch, +): Promise { + const plugins = await Promise.all( + marketplace.plugins.map(async (entry) => { + if (entry.version !== undefined) return entry; + const latest = await resolveLatestGithubRelease(entry.source, fetchImpl); + return latest === undefined ? entry : { ...entry, version: latest }; + }), + ); + return { ...marketplace, plugins }; +} + +async function readMarketplaceText( + location: MarketplaceLocation, + fetchImpl: typeof fetch, +): Promise { + if (location.kind === 'local') { + return readFile(location.resolved, 'utf8'); + } + const response = await fetchImpl(location.resolved); + if (!response.ok) { + throw new Error(`Plugin marketplace returned HTTP ${response.status}`); + } + return response.text(); +} + +function parseMarketplaceEntry( + value: unknown, + index: number, + location: MarketplaceLocation, +): PluginMarketplaceEntry { + if (!isRecord(value)) { + throw new TypeError(`Plugin marketplace entry ${index + 1} must be an object.`); + } + const id = requiredString(value, 'id', index); + validateMarketplaceEntryType(value, id); + const source = stringField(value, 'source') ?? + stringField(value, 'url') ?? + stringField(value, 'downloadUrl'); + if (source === undefined) { + throw new Error(`Plugin marketplace entry ${id} must define "source".`); + } + const resolvedSource = resolveEntrySource(source, location); + return { + id, + displayName: stringField(value, 'displayName') ?? stringField(value, 'name') ?? id, + source: resolvedSource, + tier: parseMarketplaceTier(value, id), + version: stringField(value, 'version') ?? deriveVersionFromGithubSource(resolvedSource), + description: stringField(value, 'description') ?? stringField(value, 'shortDescription'), + homepage: stringField(value, 'homepage') ?? stringField(value, 'websiteURL'), + keywords: stringArrayField(value, 'keywords'), + }; +} + +function validateMarketplaceEntryType(value: Record, id: string): void { + const raw = value['type']; + if (raw === undefined) return; + if (typeof raw !== 'string') { + throw new TypeError(`Plugin marketplace entry ${id} "type" must be a string.`); + } + const type = raw.trim(); + if (type === 'plugin' || type === 'managed' || type === 'guide') return; + throw new Error( + `Plugin marketplace entry ${id} "type" must be "plugin". Legacy aliases "managed" and "guide" are also accepted.`, + ); +} + +function parseMarketplaceTier( + value: Record, + id: string, +): PluginMarketplaceTier | undefined { + const raw = value['tier']; + if (raw === undefined) return undefined; + if (typeof raw !== 'string') { + throw new TypeError(`Plugin marketplace entry ${id} "tier" must be a string.`); + } + const tier = raw.trim(); + if (tier.length === 0) return undefined; + if ((PLUGIN_MARKETPLACE_TIERS as readonly string[]).includes(tier)) { + return tier as PluginMarketplaceTier; + } + throw new Error( + `Plugin marketplace entry ${id} "tier" must be one of: ${PLUGIN_MARKETPLACE_TIERS.join(', ')}.`, + ); +} + +function resolveEntrySource(source: string, location: MarketplaceLocation): string { + const trimmed = source.trim(); + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + return trimmed; + } + if (trimmed.startsWith('file://')) return fileURLToPath(trimmed); + if (trimmed === '~' || trimmed.startsWith('~/')) { + return resolveLocalPath(trimmed, ''); + } + if (isAbsolute(trimmed)) return trimmed; + if (location.kind === 'remote') { + return new URL(trimmed, location.resolved).toString(); + } + return resolve(dirname(location.resolved), trimmed); +} + +function deriveVersionFromGithubSource(source: string): string | undefined { + let url: URL; + try { + url = new URL(source); + } catch { + return undefined; + } + if (url.hostname !== 'github.com' && url.hostname !== 'www.github.com') { + return undefined; + } + const [, , kind, a, b] = url.pathname.split('/').filter(Boolean); + const ref = + kind === 'releases' && a === 'tag' ? b : kind === 'tree' || kind === 'commit' ? a : undefined; + if (ref === undefined) return undefined; + let decoded: string; + try { + decoded = decodeURIComponent(ref); + } catch { + decoded = ref; + } + const candidate = decoded.replace(/^v/i, ''); + return valid(candidate) !== null ? candidate : undefined; +} + +async function resolveLatestGithubRelease( + source: string, + fetchImpl: typeof fetch, +): Promise { + const repo = parseGithubRepo(source); + if (repo === undefined) return undefined; + try { + const tag = await fetchLatestReleaseTag(repo.owner, repo.repo, fetchImpl); + if (tag === undefined) return undefined; + const candidate = tag.replace(/^v/i, ''); + return valid(candidate) !== null ? candidate : undefined; + } catch { + return undefined; + } +} + +function parseGithubRepo(source: string): { owner: string; repo: string } | undefined { + let url: URL; + try { + url = new URL(source); + } catch { + return undefined; + } + if (url.hostname !== 'github.com' && url.hostname !== 'www.github.com') return undefined; + const segments = url.pathname.split('/').filter(Boolean); + if (segments.length !== 2) return undefined; + const [owner, repo] = segments; + return { owner: owner!, repo: repo! }; +} + +async function fetchLatestReleaseTag( + owner: string, + repo: string, + fetchImpl: typeof fetch, +): Promise { + const url = `https://github.com/${owner}/${repo}/releases/latest`; + const resp = await fetchImpl(url, { redirect: 'manual' }); + if (resp.status === 404) return undefined; + if (resp.status !== 301 && resp.status !== 302) { + throw new Error( + `Could not look up latest release of ${owner}/${repo}: HTTP ${resp.status} (${url}).`, + ); + } + const location = resp.headers.get('location'); + if (location === null) return undefined; + const match = /\/releases\/tag\/([^/?#]+)/.exec(location); + const tag = match?.[1]; + if (tag === undefined) return undefined; + try { + return decodeURIComponent(tag); + } catch { + return tag; + } +} + +function resolveLocalPath(input: string, workDir: string): string { + if (input === '~') return homedir(); + if (input.startsWith('~/')) return join(homedir(), input.slice(2)); + return isAbsolute(input) ? input : resolve(workDir, input); +} + +function requiredString(value: Record, field: string, index: number): string { + const result = stringField(value, field); + if (result === undefined) { + throw new Error(`Plugin marketplace entry ${index + 1} must define "${field}".`); + } + return result; +} + +function stringField(value: Record, field: string): string | undefined { + const raw = value[field]; + if (typeof raw !== 'string') return undefined; + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function stringArrayField( + value: Record, + field: string, +): readonly string[] | undefined { + const raw = value[field]; + if (!Array.isArray(raw)) return undefined; + const out = raw + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter((item) => item.length > 0); + return out.length > 0 ? out : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function formatParseError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/agent-core-v2/src/app/plugin/plugin.ts b/packages/agent-core-v2/src/app/plugin/plugin.ts new file mode 100644 index 0000000000000000000000000000000000000000..7d362c9bdd39db9a27139ae47cf7349b1ccf6437 --- /dev/null +++ b/packages/agent-core-v2/src/app/plugin/plugin.ts @@ -0,0 +1,69 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { Event } from '#/_base/event'; +import type { HookDef } from '#/features/externalHooks/internal/types'; +import type { SkillRoot } from '#/features/skill/catalog/types'; +import type { McpServerConfig } from '#/mcpCore/config-schema'; + +import type { + EnabledPluginSessionStart, + EnabledPluginSystemPrompt, + PluginAgentRoot, + PluginCommandDef, + PluginInfo, + PluginMcpServerEntry, + PluginMutationSummary, + PluginReloadEvent, + PluginSummary, + PluginUpdateStatus, + ReloadSummary, +} from './types'; + +export interface InstallPluginInput { + readonly source: string; +} + +export interface SetPluginEnabledInput { + readonly id: string; + readonly enabled: boolean; +} + +export interface SetPluginMcpServerEnabledInput { + readonly id: string; + readonly server: string; + readonly enabled: boolean; +} + +export interface RemovePluginInput { + readonly id: string; +} + +export interface GetPluginInfoInput { + readonly id: string; +} + +export interface IPluginService { + readonly _serviceBrand: undefined; + + listPlugins(): Promise; + installPlugin(input: InstallPluginInput): Promise; + setPluginEnabled(input: SetPluginEnabledInput): Promise; + setPluginMcpServerEnabled(input: SetPluginMcpServerEnabledInput): Promise; + removePlugin(input: RemovePluginInput): Promise; + reloadPlugins(): Promise; + getPluginInfo(input: GetPluginInfoInput): Promise; + listPluginCommands(): Promise; + checkUpdates(): Promise; + pluginSkillRoots(): Promise; + pluginAgentRoots(): Promise; + enabledSessionStarts(): Promise; + enabledSystemPrompts(): Promise; + enabledMcpServers(): Promise>; + mcpServerEntries(): Promise; + enabledHooks(): Promise; + hasLoadedSnapshot(): boolean; + readonly onDidReload: Event; + readonly onDidMutate: Event; +} + +export const IPluginService: ServiceIdentifier = + createDecorator('pluginService'); diff --git a/packages/agent-core-v2/src/app/plugin/pluginEvents.ts b/packages/agent-core-v2/src/app/plugin/pluginEvents.ts new file mode 100644 index 0000000000000000000000000000000000000000..68e80069c396ac70f7796fb556519e8cd2cba78c --- /dev/null +++ b/packages/agent-core-v2/src/app/plugin/pluginEvents.ts @@ -0,0 +1,13 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { Event2 } from '#/app/event/event2'; + +export class PluginChanged extends Event2<{ readonly payload: Record }> { + static override readonly type = 'event.plugin.changed'; +} +export interface PluginChanged { + readonly payload: Record; +} + +export interface PluginChangedEvent { + readonly type: 'event.plugin.changed'; +} diff --git a/packages/agent-core-v2/src/app/plugin/pluginService.ts b/packages/agent-core-v2/src/app/plugin/pluginService.ts new file mode 100644 index 0000000000000000000000000000000000000000..58fde0cbc4c62a771216ff5cc1fd3070bdbf26b0 --- /dev/null +++ b/packages/agent-core-v2/src/app/plugin/pluginService.ts @@ -0,0 +1,354 @@ +import { KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Service } from '#/_base/di/service'; +import { AsyncEmitter, Emitter, type Event } from '#/_base/event'; +import type { HookDef } from '#/features/externalHooks/internal/types'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { LifecycleScope } from '#/app/scopes'; +import { ISkillDiscovery } from '#/features/skill/catalog/skillDiscovery'; +import type { SkillRoot } from '#/features/skill/catalog/types'; +import { BugIndicatingError, Error2, PluginErrors } from '#/errors'; +import { IProviderService } from '#/llm-adapter/provider/provider'; +import type { McpServerConfig } from '#/mcpCore/config-schema'; + +import { PluginManager } from './manager'; +import { + type GetPluginInfoInput, + type InstallPluginInput, + IPluginService, + type RemovePluginInput, + type SetPluginEnabledInput, + type SetPluginMcpServerEnabledInput, +} from './plugin'; +import type { + EnabledPluginSessionStart, + EnabledPluginSystemPrompt, + PluginCommandDef, + PluginInfo, + PluginAgentRoot, + PluginMcpServerEntry, + PluginMutation, + PluginMutationSummary, + PluginReloadEvent, + PluginSummary, + PluginUpdateStatus, + ReloadSummary, +} from './types'; + +const KIMI_CODE_BASE_URL_ENV = 'KIMI_CODE_BASE_URL'; +const KIMI_CODE_OAUTH_HOST_ENV = 'KIMI_CODE_OAUTH_HOST'; +const KIMI_OAUTH_HOST_ENV = 'KIMI_OAUTH_HOST'; +const NO_ABORT = new AbortController().signal; + +interface PluginReloadNotification { + readonly summary: ReloadSummary; + readonly delivery: Promise; +} + +interface PluginMutationOutcome { + readonly result: T; + readonly notification: PluginReloadNotification; +} + +export class PluginService extends Service implements IPluginService { + declare readonly _serviceBrand: undefined; + + private readonly homeDir: string; + private readonly envBaseUrl: string | undefined; + private readonly envOAuthHost: string | undefined; + private readonly manager: PluginManager; + private initialLoadPromise: Promise | undefined; + private snapshotLoaded = false; + private loadError: Error | undefined; + private mutationQueue: Promise = Promise.resolve(); + private readonly onDidReloadEmitter = this._register(new AsyncEmitter()); + private readonly onDidMutateEmitter = this._register(new Emitter()); + + readonly onDidReload: Event = this.onDidReloadEmitter.event; + readonly onDidMutate: Event = this.onDidMutateEmitter.event; + + constructor( + @IBootstrapService bootstrap: IBootstrapService, + @ISkillDiscovery discovery: ISkillDiscovery, + @IProviderService private readonly providers: IProviderService, + ) { + super(); + this.homeDir = bootstrap.homeDir; + this.envBaseUrl = bootstrap.getEnv(KIMI_CODE_BASE_URL_ENV); + this.envOAuthHost = + bootstrap.getEnv(KIMI_CODE_OAUTH_HOST_ENV) ?? bootstrap.getEnv(KIMI_OAUTH_HOST_ENV); + this.manager = new PluginManager({ + kimiHomeDir: this.homeDir, + discoverSkills: (roots) => discovery.discover(roots), + }); + } + + listPlugins(): Promise { + return this.runManagementRead(async () => this.manager.summaries()); + } + + installPlugin(input: InstallPluginInput): Promise { + return this.runNotifiedMutation(async () => { + const record = await this.manager.install(input.source); + const info = this.manager.info(record.id); + if (info === undefined) + throw new BugIndicatingError(`Plugin "${record.id}" missing right after install`); + const notification = await this.reloadAndNotify({ + mutation: { kind: 'install', id: record.id }, + }); + return { result: info, notification }; + }); + } + + setPluginEnabled(input: SetPluginEnabledInput): Promise { + return this.runNotifiedMutation(async () => { + await this.manager.setEnabled(input.id, input.enabled); + const notification = await this.reloadAndNotify({ + mutation: { kind: input.enabled ? 'enable' : 'disable', id: input.id }, + }); + return { result: undefined, notification }; + }); + } + + setPluginMcpServerEnabled(input: SetPluginMcpServerEnabledInput): Promise { + return this.runNotifiedMutation(async () => { + await this.manager.setMcpServerEnabled(input.id, input.server, input.enabled); + const notification = await this.reloadAndNotify({ + mutation: { kind: 'mcp-server', id: input.id }, + }); + return { result: undefined, notification }; + }); + } + + removePlugin(input: RemovePluginInput): Promise { + return this.runNotifiedMutation(async () => { + await this.manager.remove(input.id); + const notification = await this.reloadAndNotify({ + mutation: { kind: 'remove', id: input.id }, + }); + return { result: undefined, notification }; + }); + } + + reloadPlugins(): Promise { + const reload = this.awaitReloadDelivery( + this.enqueueMutation(async () => { + try { + const notification = await this.reloadAndNotify(); + return { result: notification.summary, notification }; + } catch (error) { + this.loadError = error instanceof Error ? error : new Error(String(error)); + throw new Error2( + PluginErrors.codes.PLUGIN_LOAD_FAILED, + `Failed to reload plugins: ${this.loadError.message}`, + { cause: this.loadError, details: { kimiHomeDir: this.homeDir } }, + ); + } + }), + ); + this.initialLoadPromise ??= reload.then( + () => undefined, + () => undefined, + ); + return reload; + } + + private async reloadAndNotify(options?: { + readonly mutation: PluginMutation; + }): Promise { + const summary = await this.manager.reload(); + this.snapshotLoaded = true; + this.loadError = undefined; + const delivery = this.onDidReloadEmitter.fireAsyncConcurrent(summary, NO_ABORT); + if (options?.mutation !== undefined) + this.onDidMutateEmitter.fire({ ...summary, mutation: options.mutation }); + return { summary, delivery }; + } + + private runNotifiedMutation(operation: () => Promise>): Promise { + return this.awaitReloadDelivery(this.runSerializedOperation(operation)); + } + + private async awaitReloadDelivery(operation: Promise>): Promise { + const { result, notification } = await operation; + await notification.delivery; + return result; + } + + getPluginInfo(input: GetPluginInfoInput): Promise { + return this.runManagementRead(async () => { + const info = this.manager.info(input.id); + if (info === undefined) { + throw new Error2( + PluginErrors.codes.PLUGIN_NOT_FOUND, + `Plugin "${input.id}" is not installed`, + { details: { id: input.id } }, + ); + } + return info; + }); + } + + listPluginCommands(): Promise { + return this.runSerializedOperation(async () => this.manager.enabledCommands()); + } + + checkUpdates(): Promise { + return this.runManagementRead(async () => this.manager.checkUpdates()); + } + + pluginSkillRoots(): Promise { + return this.runConsumptionRead([], async () => this.manager.pluginSkillRoots()); + } + + pluginAgentRoots(): Promise { + return this.runConsumptionRead([], async () => this.manager.pluginAgentRoots()); + } + + enabledSessionStarts(): Promise { + return this.runConsumptionRead([], async () => this.manager.enabledSessionStarts()); + } + + enabledSystemPrompts(): Promise { + return this.runConsumptionRead([], async () => this.manager.enabledSystemPrompts()); + } + + enabledMcpServers(): Promise> { + return this.runConsumptionRead({}, async () => { + const pluginServers = this.manager.enabledMcpServers(); + if (!Object.values(pluginServers).some((server) => server.transport === 'stdio')) { + return pluginServers; + } + const managedEnv = await this.managedKimiCodeEnvForPlugins(); + return withManagedKimiPluginEnv(pluginServers, managedEnv); + }); + } + + mcpServerEntries(): Promise { + return this.runManagementRead(async () => { + const entries = this.manager.mcpServerEntries(); + if (!entries.some((entry) => entry.config.transport === 'stdio')) { + return entries; + } + const managedEnv = await this.managedKimiCodeEnvForPlugins(); + return withManagedKimiPluginEnvOnEntries(entries, managedEnv); + }); + } + + enabledHooks(): Promise { + return this.runConsumptionRead([], async () => this.manager.enabledHooks()); + } + + hasLoadedSnapshot(): boolean { + return this.snapshotLoaded; + } + + private runSerializedOperation(operation: () => Promise): Promise { + void this.startInitialLoad(); + return this.enqueueMutation(async () => { + this.assertLoaded(); + return operation(); + }); + } + + private async runManagementRead(operation: () => Promise): Promise { + await this.waitForPendingMutations(); + this.assertLoaded(); + return operation(); + } + + private async runConsumptionRead(fallback: T, operation: () => Promise): Promise { + await this.waitForPendingMutations(); + if (!this.snapshotLoaded) return fallback; + return operation(); + } + + private async waitForPendingMutations(): Promise { + void this.startInitialLoad(); + await this.mutationQueue; + } + + private startInitialLoad(): Promise { + this.initialLoadPromise ??= this.enqueueMutation(async () => { + await this.loadOnce(); + }); + return this.initialLoadPromise; + } + + private async loadOnce(): Promise { + try { + await this.manager.load(); + this.snapshotLoaded = true; + this.loadError = undefined; + } catch (error) { + this.loadError = error instanceof Error ? error : new Error(String(error)); + } + } + + private enqueueMutation(operation: () => Promise): Promise { + const result = this.mutationQueue.then(operation); + this.mutationQueue = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + private assertLoaded(): void { + if (this.loadError === undefined) return; + throw new Error2( + PluginErrors.codes.PLUGIN_LOAD_FAILED, + `Plugin state failed to load: ${this.loadError.message}. ` + + `Fix the file at ${this.homeDir}/plugins/installed.json and run /plugins reload.`, + { cause: this.loadError, details: { kimiHomeDir: this.homeDir } }, + ); + } + + private async managedKimiCodeEnvForPlugins(): Promise> { + await this.providers.ready; + const provider = this.providers.get(KIMI_CODE_PROVIDER_NAME); + const envBaseUrl = this.envBaseUrl; + const envOAuthHost = this.envOAuthHost; + const hasEnvOverride = envBaseUrl !== undefined || envOAuthHost !== undefined; + const baseUrl = envBaseUrl !== undefined ? envBaseUrl.replace(/\/+$/, '') : provider?.baseUrl; + const oauthHost = hasEnvOverride ? envOAuthHost : provider?.oauth?.oauthHost; + const env: Record = {}; + if (baseUrl !== undefined) env[KIMI_CODE_BASE_URL_ENV] = baseUrl; + if (oauthHost !== undefined) env[KIMI_CODE_OAUTH_HOST_ENV] = oauthHost; + return env; + } +} + +function withManagedKimiPluginEnv( + pluginServers: Record, + managedEnv: Record, +): Record { + if (Object.keys(managedEnv).length === 0) return pluginServers; + const out: Record = {}; + for (const [name, server] of Object.entries(pluginServers)) { + out[name] = + server.transport === 'stdio' ? { ...server, env: { ...server.env, ...managedEnv } } : server; + } + return out; +} + +function withManagedKimiPluginEnvOnEntries( + entries: readonly PluginMcpServerEntry[], + managedEnv: Record, +): readonly PluginMcpServerEntry[] { + if (Object.keys(managedEnv).length === 0) return entries; + return entries.map((entry) => + entry.config.transport === 'stdio' + ? { ...entry, config: { ...entry.config, env: { ...entry.config.env, ...managedEnv } } } + : entry, + ); +} + +registerScopedService( + LifecycleScope.App, + IPluginService, + PluginService, + ScopeActivation.OnScopeCreated, + 'plugin', +); diff --git a/packages/agent-core-v2/src/app/plugin/source.ts b/packages/agent-core-v2/src/app/plugin/source.ts new file mode 100644 index 0000000000000000000000000000000000000000..7909e81a98c4a666706d9540cb316320f1c39ba3 --- /dev/null +++ b/packages/agent-core-v2/src/app/plugin/source.ts @@ -0,0 +1,92 @@ +import path from 'node:path'; + +import { Error2, ErrorCodes } from '#/errors'; + +export interface GithubRef { + readonly kind: 'branch' | 'tag' | 'sha'; + readonly value: string; +} + +export type ResolvedSource = + | { kind: 'local-path'; path: string } + | { kind: 'zip-url'; path: string } + | { kind: 'github'; owner: string; repo: string; ref?: GithubRef }; + +export type InstallSource = ResolvedSource; + +const SHA_RE = /^[0-9a-f]{7,40}$/; + +export function resolveInstallSource(source: string): ResolvedSource { + const trimmed = source.trim(); + + const github = parseGithubUrl(trimmed); + if (github !== undefined) return github; + + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + return { kind: 'zip-url', path: trimmed }; + } + if (!path.isAbsolute(trimmed)) { + throw new Error2( + ErrorCodes.VALIDATION_FAILED, + `Plugin root must be an absolute path (got "${source}")`, + { details: { source } }, + ); + } + return { kind: 'local-path', path: trimmed }; +} + +function parseGithubUrl(raw: string): ResolvedSource | undefined { + let url: URL; + try { + url = new URL(raw); + } catch { + return undefined; + } + if (url.protocol !== 'https:') return undefined; + if (url.hostname !== 'github.com' && url.hostname !== 'www.github.com') return undefined; + + const segments = url.pathname.split('/').filter((s) => s.length > 0); + const owner = segments[0]; + const repoRaw = segments[1]; + if (owner === undefined || repoRaw === undefined) return undefined; + + const repo = repoRaw.endsWith('.git') ? repoRaw.slice(0, -4) : repoRaw; + const rest = segments.slice(2); + + if (rest.length === 0) { + return { kind: 'github', owner, repo }; + } + + const head = rest[0]; + const second = rest[1]; + + if (head === 'tree' && rest.length >= 2) { + const refValue = decodeRefSegments(rest.slice(1)); + const kind: GithubRef['kind'] = SHA_RE.test(refValue) ? 'sha' : 'branch'; + return { kind: 'github', owner, repo, ref: { kind, value: refValue } }; + } + + if (head === 'releases' && second === 'tag' && rest.length >= 3) { + const tag = decodeRefSegments(rest.slice(2)); + return { kind: 'github', owner, repo, ref: { kind: 'tag', value: tag } }; + } + + if (head === 'commit' && rest.length >= 2) { + const sha = decodeRefSegments(rest.slice(1)); + return { kind: 'github', owner, repo, ref: { kind: 'sha', value: sha } }; + } + + return undefined; +} + +function decodeRefSegments(segments: readonly string[]): string { + return segments + .map((segment) => { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } + }) + .join('/'); +} diff --git a/packages/agent-core-v2/src/app/plugin/store.ts b/packages/agent-core-v2/src/app/plugin/store.ts new file mode 100644 index 0000000000000000000000000000000000000000..e367c7cdee46e0481a07983bc26955c535005053 --- /dev/null +++ b/packages/agent-core-v2/src/app/plugin/store.ts @@ -0,0 +1,65 @@ +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { Error2, ErrorCodes } from '#/errors'; + +import type { PluginCapabilityState, PluginGithubMetadata, PluginSource } from './types'; + +const INSTALLED_REL = path.join('plugins', 'installed.json'); + +export interface InstalledRecord { + readonly id: string; + readonly root: string; + readonly source: PluginSource; + readonly enabled: boolean; + readonly installedAt: string; + readonly updatedAt?: string; + readonly originalSource?: string; + readonly capabilities?: PluginCapabilityState; + readonly github?: PluginGithubMetadata; +} + +export interface InstalledFile { + readonly version: 1; + readonly plugins: readonly InstalledRecord[]; +} + +const EMPTY: InstalledFile = { version: 1, plugins: [] }; + +export async function readInstalled(kimiHomeDir: string): Promise { + const filePath = path.join(kimiHomeDir, INSTALLED_REL); + let text: string; + try { + text = await readFile(filePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return EMPTY; + throw error; + } + let parsed: InstalledFile; + try { + parsed = JSON.parse(text) as InstalledFile; + } catch (error) { + throw new Error2( + ErrorCodes.PLUGIN_LOAD_FAILED, + `Failed to parse ${filePath}: ${(error as Error).message}`, + { cause: error, details: { path: filePath } }, + ); + } + if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.plugins)) { + throw new Error2( + ErrorCodes.PLUGIN_LOAD_FAILED, + `Failed to parse ${filePath}: installed.json is not a valid InstalledFile object`, + { details: { path: filePath } }, + ); + } + return parsed; +} + +export async function writeInstalled(kimiHomeDir: string, data: InstalledFile): Promise { + const dir = path.join(kimiHomeDir, 'plugins'); + await mkdir(dir, { recursive: true }); + const final = path.join(dir, 'installed.json'); + const tmp = `${final}.tmp`; + await writeFile(tmp, JSON.stringify(data, null, 2), 'utf8'); + await rename(tmp, final); +} diff --git a/packages/agent-core-v2/src/app/plugin/types.ts b/packages/agent-core-v2/src/app/plugin/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a7375fed14a12d781bf1fdfa2634d1ab72c3e4f --- /dev/null +++ b/packages/agent-core-v2/src/app/plugin/types.ts @@ -0,0 +1,200 @@ +import type { IWaitUntil } from '#/_base/event'; +import type { HookDefConfig } from '#/features/externalHooks/configSection'; +import type { McpServerConfig } from '#/mcpCore/config-schema'; + +export type PluginDiagnosticSeverity = 'error' | 'warn' | 'info'; + +export interface PluginDiagnostic { + readonly severity: PluginDiagnosticSeverity; + readonly message: string; +} + +export interface PluginAuthor { + readonly name?: string; + readonly email?: string; +} + +export interface PluginSessionStart { + readonly skill: string; +} + +export interface PluginAgentRoot { + readonly path: string; + readonly source: 'plugin'; +} + +export interface PluginInterface { + readonly displayName?: string; + readonly shortDescription?: string; + readonly longDescription?: string; + readonly developerName?: string; + readonly websiteURL?: string; +} + +export interface PluginManifest { + readonly name: string; + readonly version?: string; + readonly description?: string; + readonly keywords?: readonly string[]; + readonly author?: PluginAuthor; + readonly homepage?: string; + readonly license?: string; + readonly skills?: readonly string[]; + readonly rootSkillFallback?: boolean; + readonly agents?: readonly string[]; + readonly sessionStart?: PluginSessionStart; + readonly mcpServers?: Readonly>; + readonly hooks?: readonly HookDefConfig[]; + readonly commands?: readonly PluginCommandEntry[]; + readonly interface?: PluginInterface; + readonly skillInstructions?: string; + readonly systemPrompt?: string; +} + +export interface PluginMcpServerState { + readonly enabled: boolean; +} + +export interface PluginCapabilityState { + readonly mcpServers?: Readonly>; +} + +export interface PluginMcpServerInfo { + readonly name: string; + readonly runtimeName: string; + readonly enabled: boolean; + readonly transport: 'stdio' | 'http' | 'sse'; + readonly command?: string; + readonly args?: readonly string[]; + readonly cwd?: string; + readonly url?: string; + readonly envKeys?: readonly string[]; + readonly headerKeys?: readonly string[]; +} + +export interface PluginMcpServerEntry { + readonly name: string; + readonly config: McpServerConfig; + readonly pluginId: string; + readonly serverName: string; +} + +export interface PluginCommandDef { + readonly pluginId: string; + readonly name: string; + readonly description: string; + readonly body: string; + readonly path: string; +} + +export interface PluginCommandEntry { + readonly path: string; + readonly name: string; +} + +export type PluginManifestKind = 'kimi-plugin-root' | 'kimi-plugin-dir'; +export type PluginSource = 'local-path' | 'zip-url' | 'github'; +export type PluginState = 'ok' | 'error'; + +export interface PluginGithubRef { + readonly kind: 'branch' | 'tag' | 'sha'; + readonly value: string; +} + +export interface PluginGithubMetadata { + readonly owner: string; + readonly repo: string; + readonly ref: PluginGithubRef; + readonly installedSha?: string; +} + +export interface PluginRecord { + readonly id: string; + readonly root: string; + readonly source: PluginSource; + readonly enabled: boolean; + readonly state: PluginState; + readonly installedAt: string; + readonly updatedAt?: string; + readonly originalSource?: string; + readonly capabilities?: PluginCapabilityState; + readonly github?: PluginGithubMetadata; + readonly skillInstructions?: string; + readonly skillCount: number; + readonly manifest?: PluginManifest; + readonly manifestKind?: PluginManifestKind; + readonly manifestPath?: string; + readonly shadowedManifestPath?: string; + readonly diagnostics: readonly PluginDiagnostic[]; +} + +export interface PluginSummary { + readonly id: string; + readonly displayName: string; + readonly version?: string; + readonly enabled: boolean; + readonly state: PluginState; + readonly skillCount: number; + readonly mcpServerCount: number; + readonly enabledMcpServerCount: number; + readonly hookCount: number; + readonly commandCount: number; + readonly hasErrors: boolean; + readonly source: PluginSource; + readonly originalSource?: string; + readonly github?: PluginGithubMetadata; +} + +export interface PluginInfo extends PluginSummary { + readonly root: string; + readonly installedAt: string; + readonly updatedAt?: string; + readonly manifestKind?: PluginManifestKind; + readonly manifestPath?: string; + readonly manifest?: PluginManifest; + readonly mcpServers: readonly PluginMcpServerInfo[]; + readonly shadowedManifestPath?: string; + readonly diagnostics: readonly PluginDiagnostic[]; +} + +export interface EnabledPluginSessionStart { + readonly pluginId: string; + readonly skillName: string; +} + +export interface EnabledPluginSystemPrompt { + readonly pluginId: string; + readonly content: string; +} + +export interface ReloadSummary { + readonly added: readonly string[]; + readonly removed: readonly string[]; + readonly errors: ReadonlyArray<{ readonly id: string; readonly message: string }>; +} + +export type PluginReloadEvent = ReloadSummary & IWaitUntil; + +export interface PluginMutation { + readonly kind: 'install' | 'enable' | 'disable' | 'remove' | 'mcp-server'; + readonly id: string; +} + +export interface PluginMutationSummary extends ReloadSummary { + readonly mutation: PluginMutation; +} + +export interface PluginUpdateStatus { + readonly id: string; + readonly source: PluginSource; + readonly current?: PluginGithubRef; + readonly latest: PluginGithubRef; + readonly displayVersion: string; + readonly updateAvailable: boolean; +} + +export const PLUGIN_NAME_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/; + +export function normalizePluginId(name: string): string { + return name.toLowerCase(); +} diff --git a/packages/agent-core-v2/src/app/sessionExport/errors.ts b/packages/agent-core-v2/src/app/sessionExport/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..7efc95d0793cc59387feb31eaa4b3dab995e1d60 --- /dev/null +++ b/packages/agent-core-v2/src/app/sessionExport/errors.ts @@ -0,0 +1,12 @@ +import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; + +export const SessionExportErrors = { + codes: { + SESSION_EXPORT_NOT_FOUND: 'session.export_not_found', + SESSION_EXPORT_MISSING_VERSION: 'session.export_missing_version', + SESSION_EXPORT_OUTPUT_CONFLICT: 'session.export_output_conflict', + SESSION_EXPORT_TOO_LARGE: 'session.export_too_large', + }, +} as const satisfies ErrorDomain; + +registerErrorDomain(SessionExportErrors); diff --git a/packages/agent-core-v2/src/app/sessionExport/file-source.ts b/packages/agent-core-v2/src/app/sessionExport/file-source.ts new file mode 100644 index 0000000000000000000000000000000000000000..eaf3c636e0b4848351422ee42f6fad53d0fe82d7 --- /dev/null +++ b/packages/agent-core-v2/src/app/sessionExport/file-source.ts @@ -0,0 +1,74 @@ +import { open, type FileHandle } from 'node:fs/promises'; +import { Readable } from 'node:stream'; +import { finished } from 'node:stream/promises'; +import { resolve } from 'pathe'; + +import { Error2, ErrorCodes } from '#/errors'; + +export interface ZipSource { + readonly stream: Readable; + readonly size: number; + readonly mtime: Date; + readonly mode: number; + readonly identity: ZipSourceIdentity; + readonly sourcePath?: string; + close(): Promise; +} + +export interface ZipSourceIdentity { + readonly device: bigint; + readonly inode: bigint; +} + +export async function openZipSource(source: string, signal?: AbortSignal): Promise { + const handle = await open(source, 'r'); + let stream: Readable | undefined; + try { + signal?.throwIfAborted(); + const file = await handle.stat({ bigint: true }); + if (!file.isFile()) { + throw new Error2(ErrorCodes.FS_IS_DIRECTORY, `not a file: ${source}`, { + details: { path: source }, + }); + } + const size = Number(file.size); + if (!Number.isSafeInteger(size)) { + throw new Error2(ErrorCodes.SESSION_EXPORT_TOO_LARGE, `file is too large to export: ${source}`, { + details: { path: source }, + }); + } + signal?.throwIfAborted(); + stream = + size === 0 + ? Readable.from([]) + : handle.createReadStream({ + autoClose: false, + start: 0, + end: size - 1, + signal, + }); + let closing: Promise | undefined; + return { + stream, + size, + mtime: file.mtime, + mode: Number(file.mode), + identity: { device: file.dev, inode: file.ino }, + sourcePath: resolve(source), + close: () => { + closing ??= closeZipSource(stream!, handle); + return closing; + }, + }; + } catch (error) { + stream?.destroy(); + await handle.close().catch(() => {}); + throw error; + } +} + +async function closeZipSource(stream: Readable, handle: FileHandle): Promise { + stream.destroy(); + await finished(stream, { cleanup: true }).catch(() => {}); + await handle.close(); +} diff --git a/packages/agent-core-v2/src/app/sessionExport/manifest.ts b/packages/agent-core-v2/src/app/sessionExport/manifest.ts new file mode 100644 index 0000000000000000000000000000000000000000..19889912aa33935ba42cddf7e97071fe9c2fe34b --- /dev/null +++ b/packages/agent-core-v2/src/app/sessionExport/manifest.ts @@ -0,0 +1,52 @@ +import { WIRE_PROTOCOL_VERSION } from '#/wire/migration/migration'; + +import type { + ExportSessionManifest, + ShellEnvironment, +} from './sessionExport'; +import type { SessionWireScan } from './wire-scan'; + +export interface ExportSessionManifestSummary { + readonly id: string; + readonly title?: string | undefined; + readonly workspaceDir?: string | undefined; +} + +export function buildExportManifest(args: { + readonly summary: ExportSessionManifestSummary; + readonly now: Date; + readonly version: string; + readonly wireProtocolVersion?: string | undefined; + readonly sessionScan: SessionWireScan; + readonly sessionLogPath?: string | undefined; + readonly globalLogPath?: string | undefined; + readonly webLogPath?: string; + readonly desktopVersion?: string; + readonly installSource?: string | undefined; + readonly shellEnv?: ShellEnvironment | undefined; +}): ExportSessionManifest { + return { + sessionId: args.summary.id, + exportedAt: args.now.toISOString(), + kimiCodeVersion: args.version, + wireProtocolVersion: args.wireProtocolVersion ?? WIRE_PROTOCOL_VERSION, + os: `${process.platform} ${process.arch}`, + nodejsVersion: process.version.replace(/^v/, ''), + sessionFirstActivity: + args.sessionScan.firstActivityMs === undefined + ? undefined + : new Date(args.sessionScan.firstActivityMs).toISOString(), + sessionLastActivity: + args.sessionScan.lastActivityMs === undefined + ? undefined + : new Date(args.sessionScan.lastActivityMs).toISOString(), + title: args.summary.title, + workspaceDir: args.summary.workspaceDir, + sessionLogPath: args.sessionLogPath, + globalLogPath: args.globalLogPath, + webLogPath: args.webLogPath, + desktopVersion: args.desktopVersion, + installSource: args.installSource, + shellEnv: args.shellEnv, + }; +} diff --git a/packages/agent-core-v2/src/app/sessionExport/sessionExport.ts b/packages/agent-core-v2/src/app/sessionExport/sessionExport.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f782dca195186fe2f13bf767922c4857621bcb7 --- /dev/null +++ b/packages/agent-core-v2/src/app/sessionExport/sessionExport.ts @@ -0,0 +1,64 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface ShellEnvironment { + readonly term?: string | undefined; + readonly termProgram?: string | undefined; + readonly termProgramVersion?: string | undefined; + readonly multiplexer?: string | undefined; + readonly shell?: string | undefined; +} + +export interface ExportSessionPayload { + readonly sessionId: string; + readonly outputPath?: string | undefined; + readonly includeGlobalLog?: boolean | undefined; + readonly includeDesktopLog?: boolean; + readonly version: string; + readonly desktopVersion?: string; + readonly installSource?: string | undefined; + readonly shellEnv?: ShellEnvironment | undefined; +} + +export interface ExportSessionManifest { + readonly sessionId: string; + readonly exportedAt: string; + readonly kimiCodeVersion: string; + readonly wireProtocolVersion: string; + readonly os: string; + readonly nodejsVersion: string; + readonly sessionFirstActivity?: string | undefined; + readonly sessionLastActivity?: string | undefined; + readonly title?: string | undefined; + readonly workspaceDir?: string | undefined; + readonly sessionLogPath?: string | undefined; + readonly globalLogPath?: string | undefined; + readonly desktopLogPath?: string; + readonly webLogPath?: string; + readonly desktopVersion?: string; + readonly installSource?: string | undefined; + readonly shellEnv?: ShellEnvironment | undefined; +} + +export interface ExportSessionResult { + readonly zipPath: string; + readonly entries: readonly string[]; + readonly sessionDir: string; + readonly manifest: ExportSessionManifest; +} + +export interface ExportSessionOptions { + readonly webLog?: string; + readonly signal?: AbortSignal; +} + +export interface ISessionExportService { + readonly _serviceBrand: undefined; + + export( + input: ExportSessionPayload, + options?: ExportSessionOptions, + ): Promise; +} + +export const ISessionExportService: ServiceIdentifier = + createDecorator('sessionExportService'); diff --git a/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts new file mode 100644 index 0000000000000000000000000000000000000000..55f8f650fe269d77be61f6e7201a2df85830a237 --- /dev/null +++ b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts @@ -0,0 +1,319 @@ +import { join, resolve } from 'pathe'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import type { ISessionScopeHandle } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { resolveGlobalLogPath } from '#/_base/log/logConfig'; +import { IWireService } from '#/wire/wire'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; +import { ISessionManager } from '#/app/sessionManager/sessionManager'; +import { IWorkspaceService } from '#/app/workspace/workspace'; +import { + sessionDirOf, + workspacePersistenceScope, +} from '#/workspace/sessionLifecycle/internal/addressing'; +import { ErrorCodes, Error2 } from '#/errors'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; + +import { buildExportManifest, type ExportSessionManifestSummary } from './manifest'; +import { + type ExportSessionPayload, + type ExportSessionResult, + type ExportSessionOptions, + ISessionExportService, +} from './sessionExport'; +import { scanSessionWire } from './wire-scan'; +import { + type ExtraZipEntry, + type SessionZipEntry, + collectFilesRecursive, + writeExportZip, +} from './zip'; +import { openZipSource, type ZipSource } from './file-source'; +import { FILE_HISTORY_BLOB_PREFIX } from '#/features/fileHistory/fileHistoryService'; + +const SESSION_LOG_REL = 'logs/kimi-code.log'; +const GLOBAL_LOG_REL = 'logs/global/kimi-code.log'; +const WEB_LOG_REL = 'logs/kimi-web.jsonl'; +const DESKTOP_LOG_REL = 'logs/kimi-desktop.log'; + +export class SessionExportService implements ISessionExportService { + declare readonly _serviceBrand: undefined; + + constructor( + @IBootstrapService private readonly bootstrap: IBootstrapService, + @ISessionIndex private readonly index: ISessionIndex, + @ISessionManager private readonly sessions: ISessionManager, + @IWorkspaceService private readonly workspaces: IWorkspaceService, + @ILogService private readonly log: ILogService, + ) {} + + async export( + input: ExportSessionPayload, + options: ExportSessionOptions = {}, + ): Promise { + options.signal?.throwIfAborted(); + if (input.version.trim().length === 0) { + throw new Error2( + ErrorCodes.SESSION_EXPORT_MISSING_VERSION, + 'Session export requires a host version.', + { details: { sessionId: input.sessionId } }, + ); + } + + const summary = await this.index.get(input.sessionId); + if (summary === undefined) { + throw new Error2( + ErrorCodes.SESSION_NOT_FOUND, + `Session "${input.sessionId}" does not exist`, + { details: { sessionId: input.sessionId } }, + ); + } + + const liveSummary = await this.flushLiveSession(summary); + options.signal?.throwIfAborted(); + if (input.includeGlobalLog === true) { + await this.warnIfFails('export global log flush failed', () => this.log.flush(), { + retry: true, + }); + } + + return exportSessionDirectory({ + request: input, + summary: liveSummary, + globalLogPath: resolveGlobalLogPath(this.bootstrap.homeDir), + desktopLogPath: + input.includeDesktopLog === true + ? join(this.bootstrap.homeDir, 'logs', 'kimi-code-desktop.log') + : undefined, + webLog: options.webLog, + signal: options.signal, + }); + } + + private async flushLiveSession(summary: SessionSummary): Promise { + const workspace = await this.workspaces.get(summary.workspaceId); + const sessionDir = sessionDirOf( + this.bootstrap.homeDir, + workspacePersistenceScope(this.bootstrap.scope('sessions'), summary.workspaceId), + summary.id, + ); + let exportSummary: ExportSessionDirectorySummary = { + id: summary.id, + title: summary.title, + workspaceDir: workspace?.root, + sessionDir, + }; + const handle = this.liveSession(summary.id); + if (handle === undefined) { + return exportSummary; + } + + try { + const metadata = handle.accessor.get(ISessionMetadata); + await metadata.ready; + const meta = await metadata.read(); + exportSummary = { + id: meta.id, + title: meta.title, + workspaceDir: workspace?.root, + sessionDir, + }; + } catch (error) { + this.log.warn('flushMetadata failed before export', { error }); + } + + await this.warnIfFails('export session log flush failed', () => + handle.accessor.get(ILogService).flush(), + ); + const agents = handle.accessor.get(IAgentLifecycleService); + for (const agent of agents.list()) { + const agentHandle = agents.handleOf(agent.agentId); + if (agentHandle === undefined) continue; + await this.warnIfFails('export agent wire flush failed', () => + agentHandle.accessor.get(IWireService).flush(), + ); + } + + return exportSummary; + } + + private liveSession(sessionId: string): ISessionScopeHandle | undefined { + return this.sessions.get(sessionId); + } + + private async warnIfFails( + message: string, + operation: () => Promise, + options: { readonly retry?: boolean } = {}, + ): Promise { + try { + await operation(); + return; + } catch (error) { + this.log.warn(message, { error }); + } + if (options.retry !== true) return; + try { + await operation(); + } catch {} + } +} + +export interface ExportSessionDirectorySummary extends ExportSessionManifestSummary { + readonly sessionDir: string; +} + +export async function exportSessionDirectory(input: { + readonly request: ExportSessionPayload; + readonly summary: ExportSessionDirectorySummary; + readonly globalLogPath?: string | undefined; + readonly desktopLogPath?: string | undefined; + readonly webLog?: string; + readonly signal?: AbortSignal; +}): Promise { + input.signal?.throwIfAborted(); + const sessionDir = input.summary.sessionDir; + const sessionLogPath = join(sessionDir, SESSION_LOG_REL); + let sessionLogSource: ZipSource | undefined; + let sessionLogSourceTransferred = false; + let globalSource: ZipSource | undefined; + let globalSourceTransferred = false; + let desktopSource: ZipSource | undefined; + let desktopSourceTransferred = false; + + try { + sessionLogSource = await openOptionalZipSource(sessionLogPath, input.signal); + if (input.request.includeGlobalLog === true && input.globalLogPath !== undefined) { + globalSource = await openOptionalZipSource(input.globalLogPath, input.signal); + } + if (input.desktopLogPath !== undefined) { + desktopSource = await openOptionalZipSource(input.desktopLogPath, input.signal); + } + const sessionFiles = await collectFilesRecursive(sessionDir); + if (sessionFiles.length === 0 && sessionLogSource === undefined) { + throw new Error2( + ErrorCodes.SESSION_EXPORT_NOT_FOUND, + `Session "${input.summary.id}" has no exportable directory at "${sessionDir}"`, + { details: { sessionId: input.summary.id, sessionDir } }, + ); + } + + const sessionScan = await scanSessionWire(sessionDir, input.signal); + const stableSessionLog = sessionLogSource; + const selectedSessionFiles: SessionZipEntry[] = sessionFiles.filter( + (file) => + file !== sessionLogPath && !file.split(/[\\/]/).includes(FILE_HISTORY_BLOB_PREFIX), + ); + if (stableSessionLog !== undefined) { + selectedSessionFiles.push({ path: sessionLogPath, source: stableSessionLog }); + selectedSessionFiles.sort((left, right) => + sessionZipEntryPath(left).localeCompare(sessionZipEntryPath(right)), + ); + } + const bundledWebLog = input.webLog !== undefined; + const now = new Date(); + const baseManifest = buildExportManifest({ + summary: input.summary, + now, + version: input.request.version, + sessionScan, + sessionLogPath: stableSessionLog === undefined ? undefined : SESSION_LOG_REL, + webLogPath: bundledWebLog ? WEB_LOG_REL : undefined, + desktopVersion: input.request.desktopVersion, + installSource: input.request.installSource, + shellEnv: input.request.shellEnv, + }); + const outputPath = + input.request.outputPath !== undefined + ? resolve(input.request.outputPath) + : resolve(defaultExportZipName(input.summary.id, now)); + const extras: ExtraZipEntry[] = []; + if (input.webLog !== undefined) { + extras.push({ data: Buffer.from(input.webLog, 'utf8'), target: WEB_LOG_REL }); + } + if (globalSource !== undefined) { + extras.push({ source: globalSource, target: GLOBAL_LOG_REL }); + } + if (desktopSource !== undefined) { + extras.push({ source: desktopSource, target: DESKTOP_LOG_REL }); + } + const manifest = { + ...baseManifest, + globalLogPath: globalSource === undefined ? undefined : GLOBAL_LOG_REL, + desktopLogPath: desktopSource === undefined ? undefined : DESKTOP_LOG_REL, + }; + + const writing = writeExportZip({ + outputPath, + manifest, + sessionDir, + sessionFiles: selectedSessionFiles, + extraEntries: extras, + signal: input.signal, + }); + sessionLogSourceTransferred = sessionLogSource !== undefined; + globalSourceTransferred = globalSource !== undefined; + desktopSourceTransferred = desktopSource !== undefined; + const entries = await writing; + + return { + zipPath: outputPath, + entries, + sessionDir, + manifest, + }; + } finally { + if (sessionLogSource !== undefined && !sessionLogSourceTransferred) { + await sessionLogSource.close().catch(() => {}); + } + if (globalSource !== undefined && !globalSourceTransferred) { + await globalSource.close().catch(() => {}); + } + if (desktopSource !== undefined && !desktopSourceTransferred) { + await desktopSource.close().catch(() => {}); + } + } +} + +function defaultExportZipName(sessionId: string, now: Date): string { + const shortId = sessionId.slice(0, 8); + const timestamp = now.toISOString().replaceAll(/[-:]/g, '').replace(/T/, '-').slice(0, 15); + return `kimi-debug-${shortId}-${timestamp}.zip`; +} + +function sessionZipEntryPath(entry: SessionZipEntry): string { + return typeof entry === 'string' ? entry : entry.path; +} + +async function openOptionalZipSource( + path: string, + signal: AbortSignal | undefined, +): Promise { + try { + return await openZipSource(path, signal); + } catch (error) { + signal?.throwIfAborted(); + if (isMissingPath(error)) return undefined; + throw error; + } +} + +function isMissingPath(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} + +registerScopedService( + LifecycleScope.App, + ISessionExportService, + SessionExportService, + ScopeActivation.OnScopeCreated, + 'sessionExport', +); diff --git a/packages/agent-core-v2/src/app/sessionExport/wire-scan.ts b/packages/agent-core-v2/src/app/sessionExport/wire-scan.ts new file mode 100644 index 0000000000000000000000000000000000000000..11cd45b8b360efd7b3fef6c60479b5a50bde2f01 --- /dev/null +++ b/packages/agent-core-v2/src/app/sessionExport/wire-scan.ts @@ -0,0 +1,166 @@ +import { open, readdir, type FileHandle } from 'node:fs/promises'; +import { createInterface } from 'node:readline'; +import { Readable } from 'node:stream'; +import { finished } from 'node:stream/promises'; + +import { join } from 'pathe'; + +const WIRE_FILENAME = 'wire.jsonl'; + +export interface SessionWireScan { + readonly firstActivityMs?: number | undefined; + readonly lastActivityMs?: number | undefined; + readonly lastUserMessageMs?: number | undefined; + readonly firstUserInput?: string | undefined; +} + +export async function scanSessionWire( + sessionDir: string, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + const wireFiles = await collectWireFiles(sessionDir, signal); + let firstActivityMs: number | undefined; + let lastActivityMs: number | undefined; + let lastUserMessageMs: number | undefined; + let firstUserInput: string | undefined; + + for (const file of wireFiles) { + signal?.throwIfAborted(); + const scan = await scanWireFile(file, signal); + firstActivityMs = minDefined(firstActivityMs, scan.firstActivityMs); + lastActivityMs = maxDefined(lastActivityMs, scan.lastActivityMs); + lastUserMessageMs = maxDefined(lastUserMessageMs, scan.lastUserMessageMs); + firstUserInput ??= scan.firstUserInput; + } + + return { + firstActivityMs, + lastActivityMs, + lastUserMessageMs, + firstUserInput, + }; +} + +async function collectWireFiles( + sessionDir: string, + signal?: AbortSignal, +): Promise { + const files = [join(sessionDir, WIRE_FILENAME)]; + const agentsDir = join(sessionDir, 'agents'); + try { + const entries = await readdir(agentsDir, { recursive: true, withFileTypes: true }); + for (const entry of entries) { + signal?.throwIfAborted(); + if (!entry.isFile() || entry.name !== WIRE_FILENAME) continue; + files.push(join(entry.parentPath, entry.name)); + } + } catch (error) { + if (!isMissingPath(error)) throw error; + } + return files; +} + +async function scanWireFile(path: string, signal?: AbortSignal): Promise { + let file: FileHandle; + try { + file = await open(path, 'r'); + } catch (error) { + if (!isMissingPath(error)) throw error; + return {}; + } + + let input: Readable | undefined; + + let firstActivityMs: number | undefined; + let lastActivityMs: number | undefined; + let lastUserMessageMs: number | undefined; + let firstUserInput: string | undefined; + + try { + signal?.throwIfAborted(); + const size = (await file.stat()).size; + signal?.throwIfAborted(); + input = + size === 0 + ? Readable.from([]) + : file.createReadStream({ + encoding: 'utf8', + autoClose: false, + end: size - 1, + signal, + }); + const lines = createInterface({ input, crlfDelay: Infinity }); + for await (const line of lines) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed) as unknown; + } catch { + continue; + } + if (typeof parsed !== 'object' || parsed === null) continue; + const record = parsed as { + type?: unknown; + time?: unknown; + userInput?: unknown; + }; + const timeMs = + typeof record.time === 'number' ? normalizeTimestampMs(record.time) : undefined; + if (timeMs !== undefined) { + firstActivityMs = minDefined(firstActivityMs, timeMs); + lastActivityMs = maxDefined(lastActivityMs, timeMs); + } + if (record.type === 'turn_begin') { + if (timeMs !== undefined) { + lastUserMessageMs = maxDefined(lastUserMessageMs, timeMs); + } + if ( + firstUserInput === undefined && + typeof record.userInput === 'string' && + record.userInput.trim().length > 0 + ) { + firstUserInput = record.userInput; + } + } + } + } finally { + input?.destroy(); + if (input !== undefined) await finished(input, { cleanup: true }).catch(() => {}); + await file.close(); + } + + return { + firstActivityMs, + lastActivityMs, + lastUserMessageMs, + firstUserInput, + }; +} + +export function normalizeTimestampMs(value: number): number | undefined { + if (!Number.isFinite(value) || value <= 0) return undefined; + return value > 1e12 ? Math.floor(value) : Math.floor(value * 1000); +} + +function minDefined(a: number | undefined, b: number | undefined): number | undefined { + if (a === undefined) return b; + if (b === undefined) return a; + return Math.min(a, b); +} + +function maxDefined(a: number | undefined, b: number | undefined): number | undefined { + if (a === undefined) return b; + if (b === undefined) return a; + return Math.max(a, b); +} + +function isMissingPath(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/packages/agent-core-v2/src/app/sessionExport/zip.ts b/packages/agent-core-v2/src/app/sessionExport/zip.ts new file mode 100644 index 0000000000000000000000000000000000000000..19503e43b489c4cde76544f58bb57cc988be16b8 --- /dev/null +++ b/packages/agent-core-v2/src/app/sessionExport/zip.ts @@ -0,0 +1,325 @@ +import { createWriteStream } from 'node:fs'; +import { mkdir, mkdtemp, readdir, rename, rm, stat } from 'node:fs/promises'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +import { dirname, join, relative, resolve } from 'pathe'; +import { ZipFile, type ReadStreamOptions } from 'yazl'; + +import { ErrorCodes, Error2 } from '#/errors'; + +import { + openZipSource, + type ZipSource, + type ZipSourceIdentity, +} from './file-source'; +import type { ExportSessionManifest } from './sessionExport'; + +export async function collectFilesRecursive(root: string): Promise { + try { + const entries = await readdir(root, { recursive: true, withFileTypes: true }); + return entries + .filter((entry) => entry.isFile()) + .map((entry) => join(entry.parentPath, entry.name)) + .toSorted((a, b) => a.localeCompare(b)); + } catch (error) { + if (!isMissingPath(error)) throw error; + return []; + } +} + +export type ExtraZipEntry = + | { readonly source: ZipSource; readonly target: string } + | { readonly data: Buffer; readonly target: string }; + +export type SessionZipEntry = string | { readonly path: string; readonly source: ZipSource }; + +export async function writeExportZip(args: { + readonly outputPath: string; + readonly manifest: ExportSessionManifest; + readonly sessionDir: string; + readonly sessionFiles: readonly SessionZipEntry[]; + readonly extraEntries?: readonly ExtraZipEntry[]; + readonly signal?: AbortSignal; +}): Promise { + const unusedSources = new Set([ + ...args.sessionFiles.flatMap((entry) => (typeof entry === 'string' ? [] : [entry.source])), + ...(args.extraEntries ?? []).flatMap((entry) => + 'source' in entry ? [entry.source] : [], + ), + ]); + const pendingOpens = new Set>(); + let activeSource: ZipSource | undefined; + let releaseActive: (() => void) | undefined; + let closing = Promise.resolve(); + let output: Readable | undefined; + let writing: Promise | undefined; + let stopped: Error | undefined; + let failure: { readonly error: unknown } | undefined; + let onAbort: (() => void) | undefined; + let tempDir: string | undefined; + + const getStopError = (): Error | undefined => stopped; + const stop = (error: Error): void => { + stopped ??= error; + releaseActive?.(); + if (output !== undefined && !output.destroyed) output.destroy(stopped); + }; + const queueClose = (source: ZipSource): void => { + const next = closing.catch(() => {}).then(() => source.close()); + closing = next; + void next.catch((error: unknown) => { + stop(asError(error)); + }); + }; + + try { + const conflictingSource = await findConflictingSource(args); + if (conflictingSource !== undefined) { + throw new Error2( + ErrorCodes.SESSION_EXPORT_OUTPUT_CONFLICT, + `Session export output conflicts with selected source "${conflictingSource}".`, + { details: { outputPath: args.outputPath, source: conflictingSource } }, + ); + } + await mkdir(dirname(args.outputPath), { recursive: true }); + args.signal?.throwIfAborted(); + tempDir = await mkdtemp(join(dirname(args.outputPath), '.kimi-session-export-')); + const tempOutputPath = join(tempDir, 'archive.zip'); + + const zip = new ZipFile() as LazyZipFile; + output = zip.outputStream as unknown as Readable; + zip.on('error', (error: Error) => { + stop(error); + }); + output.on('error', (error: Error) => { + stop(error); + }); + onAbort = (): void => { + stop(abortReason(args.signal!)); + }; + args.signal?.addEventListener('abort', onAbort, { once: true }); + + const destination = createWriteStream(tempOutputPath, { flags: 'wx' }); + writing = pipeline(output, destination, { signal: args.signal }); + + const activate = (source: ZipSource): Readable => { + unusedSources.delete(source); + activeSource = source; + let released = false; + const release = (): void => { + if (released) return; + released = true; + if (activeSource === source) activeSource = undefined; + if (releaseActive === release) releaseActive = undefined; + queueClose(source); + }; + releaseActive = release; + source.stream.once('end', release); + source.stream.once('close', release); + source.stream.once('error', (error: Error) => { + release(); + zip.emit('error', error); + }); + return source.stream; + }; + + const addLazySource = ( + target: string, + options: Partial, + getSource: () => Promise, + ): void => { + zip.addReadStreamLazy(target, options, (callback) => { + const pending = (async (): Promise => { + try { + await closing; + if (stopped !== undefined) throw stopped; + const source = await getSource(); + const stopError = getStopError(); + if (stopError !== undefined) { + await source.close().catch(() => {}); + throw stopError; + } + callback(null, activate(source)); + } catch (error) { + callback(asError(error)); + } + })(); + pendingOpens.add(pending); + void pending.then( + () => pendingOpens.delete(pending), + (error: unknown) => { + pendingOpens.delete(pending); + zip.emit('error', asError(error)); + }, + ); + }); + }; + + zip.addBuffer(Buffer.from(JSON.stringify(args.manifest, null, 2), 'utf8'), 'manifest.json'); + + for (const entry of args.sessionFiles) { + const sourcePath = sessionEntryPath(entry); + const target = relative(args.sessionDir, sourcePath).split(/[\\/]/).join('/'); + if (typeof entry === 'string') { + addLazySource(target, {}, () => openZipSource(entry, args.signal)); + } else { + addLazySource( + target, + { size: entry.source.size, mtime: entry.source.mtime, mode: entry.source.mode }, + async () => entry.source, + ); + } + } + + for (const extra of args.extraEntries ?? []) { + if ('data' in extra) { + zip.addBuffer(extra.data, extra.target); + } else { + addLazySource( + extra.target, + { size: extra.source.size, mtime: extra.source.mtime, mode: extra.source.mode }, + async () => extra.source, + ); + } + } + + zip.end(); + await writing; + await Promise.allSettled(pendingOpens); + await closing; + if (onAbort !== undefined) { + args.signal?.removeEventListener('abort', onAbort); + onAbort = undefined; + } + args.signal?.throwIfAborted(); + await rename(tempOutputPath, args.outputPath); + } catch (error) { + failure = { error }; + stop(asError(error)); + await writing?.catch(() => {}); + } finally { + if (onAbort !== undefined) args.signal?.removeEventListener('abort', onAbort); + await Promise.allSettled(pendingOpens); + releaseActive?.(); + for (const source of unusedSources) queueClose(source); + unusedSources.clear(); + try { + await closing; + } catch (error) { + failure ??= { error }; + } + if (tempDir !== undefined) { + try { + await rm(tempDir, { recursive: true, force: true }); + } catch (error) { + failure ??= { error }; + } + } + } + + if (failure !== undefined) throw failure.error; + if (stopped !== undefined) throw stopped; + return [ + 'manifest.json', + ...args.sessionFiles.map((entry) => + relative(args.sessionDir, sessionEntryPath(entry)).split(/[\\/]/).join('/'), + ), + ...(args.extraEntries ?? []).map((entry) => entry.target), + ]; +} + +interface LazyZipFile extends ZipFile { + addReadStreamLazy( + target: string, + options: Partial, + getReadStream: ( + callback: (error: Error | null, stream?: NodeJS.ReadableStream) => void, + ) => void, + ): void; +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function abortReason(signal: AbortSignal): Error { + return signal.reason instanceof Error + ? signal.reason + : new DOMException('The operation was aborted.', 'AbortError'); +} + +async function findConflictingSource(args: { + readonly outputPath: string; + readonly sessionFiles: readonly SessionZipEntry[]; + readonly extraEntries?: readonly ExtraZipEntry[]; + readonly signal?: AbortSignal; +}): Promise { + args.signal?.throwIfAborted(); + const outputPath = resolve(args.outputPath); + for (const entry of args.sessionFiles) { + const sourcePath = sessionEntryPath(entry); + if (resolve(sourcePath) === outputPath) return sourcePath; + } + for (const entry of args.extraEntries ?? []) { + if ( + 'source' in entry && + entry.source.sourcePath !== undefined && + resolve(entry.source.sourcePath) === outputPath + ) { + return entry.target; + } + } + + const output = await statExisting(outputPath); + if (output === undefined) return undefined; + + for (const entry of args.sessionFiles) { + args.signal?.throwIfAborted(); + const input = + typeof entry === 'string' ? await statExisting(entry) : entry.source.identity; + if (input !== undefined && sameFile(output, input)) return sessionEntryPath(entry); + } + for (const entry of args.extraEntries ?? []) { + args.signal?.throwIfAborted(); + if ('source' in entry && sameFile(output, entry.source.identity)) return entry.target; + } + return undefined; +} + +async function statExisting( + path: string, +): Promise { + try { + const file = await stat(path, { bigint: true }); + return { device: file.dev, inode: file.ino }; + } catch (error) { + if (!isMissingPath(error)) throw error; + return undefined; + } +} + +function sameFile( + left: ZipSourceIdentity, + right: ZipSourceIdentity, +): boolean { + return ( + left.inode !== 0n && + right.inode !== 0n && + left.device === right.device && + left.inode === right.inode + ); +} + +function sessionEntryPath(entry: SessionZipEntry): string { + return typeof entry === 'string' ? entry : entry.path; +} + +function isMissingPath(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts new file mode 100644 index 0000000000000000000000000000000000000000..58a182a8ba4a5dadd2a323efe2f8003d61c4ccc6 --- /dev/null +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts @@ -0,0 +1,72 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { Page } from '#/persistence/interface/queryStore'; + +export const PARENT_SESSION_ID_KEY = 'parent_session_id'; + +export const CHILD_SESSION_KIND_KEY = 'child_session_kind'; + +export const CHILD_SESSION_KIND = 'child'; + +export interface SessionSummary { + readonly id: string; + readonly workspaceId: string; + readonly cwd?: string; + readonly title?: string; + readonly lastPrompt?: string; + readonly createdAt: number; + readonly updatedAt: number; + readonly archived: boolean; + readonly archivedAt?: number; + readonly custom?: Record; + readonly lastTurnReason?: 'completed' | 'cancelled' | 'failed'; +} + +export interface SessionListQuery { + readonly workspaceIds?: readonly string[]; + readonly sessionId?: string; + readonly includeArchived?: boolean; + readonly limit?: number; + readonly childOf?: string; + readonly before?: string; + readonly after?: string; +} + +export interface SessionCountQuery { + readonly workspaceIds?: readonly string[]; + readonly includeArchived?: boolean; +} + +export type SessionIndexState = 'uninitialized' | 'preparing' | 'ready' | 'degraded'; + +export interface SessionIndexStatus { + readonly state: SessionIndexState; + readonly generation?: number; + readonly reason?: string; + readonly degradedCount: number; +} + +export interface ISessionIndex { + readonly _serviceBrand: undefined; + + prepare(options?: { deadlineMs?: number }): Promise; + status(): SessionIndexStatus; + get(id: string): Promise; + listRecent(query: SessionListQuery): Promise>; + count(query: SessionCountQuery): Promise; + remove(id: string): Promise; +} + +export const ISessionIndex: ServiceIdentifier = + createDecorator('sessionIndex'); + +export interface ISessionIndexMirror { + readonly _serviceBrand: undefined; + + record(summary: SessionSummary): void; + pending(): readonly SessionSummary[]; + evict(id: string): Promise; + drain(): Promise; +} + +export const ISessionIndexMirror: ServiceIdentifier = + createDecorator('sessionIndexMirror'); diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexDirtyJournal.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexDirtyJournal.ts new file mode 100644 index 0000000000000000000000000000000000000000..2c5c46c50a6acaa314e4f592731636b7cebf8da7 --- /dev/null +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexDirtyJournal.ts @@ -0,0 +1,47 @@ +import { IFileSystemStorageService } from '#/persistence/interface/storage'; + +export const SESSION_INDEX_DIRTY_DIR = '.index-dirty'; + +const EMPTY = new Uint8Array(0); + +function dirtyScope(sessionsScope: string): string { + return `${sessionsScope}/${SESSION_INDEX_DIRTY_DIR}`; +} + +export async function markSessionDirty( + storage: IFileSystemStorageService, + sessionsScope: string, + sessionId: string, +): Promise { + await storage.append(dirtyScope(sessionsScope), `${sessionId}.${Date.now()}`, EMPTY, { + durable: false, + }); +} + +export async function listDirtyMarks( + storage: IFileSystemStorageService, + sessionsScope: string, +): Promise { + return storage.list(dirtyScope(sessionsScope)); +} + +export function dirtyMarkSessionIds(marks: readonly string[]): Set { + const ids = new Set(); + for (const name of marks) { + const dot = name.lastIndexOf('.'); + if (dot > 0) ids.add(name.slice(0, dot)); + } + return ids; +} + +export async function clearDirtyMarks( + storage: IFileSystemStorageService, + sessionsScope: string, + marks: readonly string[], +): Promise { + await Promise.all( + marks.map((name) => + storage.delete(dirtyScope(sessionsScope), name).catch(() => undefined), + ), + ); +} diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexMirrorService.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexMirrorService.ts new file mode 100644 index 0000000000000000000000000000000000000000..61ac995c14897af704dda9f4666349e8ff6334cc --- /dev/null +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexMirrorService.ts @@ -0,0 +1,226 @@ +import { Disposable, toDisposable } from '#/_base/di/lifecycle'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { IntervalTimer } from '#/_base/utils/timer'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigService } from '#/app/config/config'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { databaseBaseEnabled } from '#/persistence/configSection'; +import { IQueryStore } from '#/persistence/interface/queryStore'; +import { IFileSystemStorageService } from '#/persistence/interface/storage'; + +import { ISessionIndexMirror, type SessionSummary } from './sessionIndex'; +import { markSessionDirty } from './sessionIndexDirtyJournal'; +import { + SESSION_INDEX_MANIFEST, + recencyColumn, + sessionCollection, + sessionCountersCollection, + withRecencyField, + type SessionWorkspaceCounts, +} from './sessionIndexModel'; + +const FLUSH_INTERVAL_MS = 100; +const FLUSH_BATCH_SIZE = 500; +const MAX_PENDING = 10_000; +const MAX_CONSECUTIVE_FAILURES = 5; + +const pendingDrains = new Set>(); + +export async function drainSessionIndexMirror(): Promise { + await Promise.all(pendingDrains); +} + +export class SessionIndexMirror extends Disposable implements ISessionIndexMirror { + declare readonly _serviceBrand: undefined; + + private readonly pendingMap = new Map(); + private readonly pendingMarks = new Set>(); + private readonly timer = this._register(new IntervalTimer({ unref: true })); + private flushing: Promise | undefined; + private consecutiveFailures = 0; + private giveUpTracked = false; + private disposed = false; + private overflowLogged = false; + private readonly sessionsScope: string; + + constructor( + @IQueryStore private readonly queryStore: IQueryStore, + @IConfigService private readonly config: IConfigService, + @ITelemetryService private readonly telemetry: ITelemetryService, + @ILogService private readonly log: ILogService, + @IFileSystemStorageService private readonly storage: IFileSystemStorageService, + @IBootstrapService bootstrap: IBootstrapService, + ) { + super(); + this.sessionsScope = bootstrap.scope('sessions'); + this._register( + toDisposable(() => { + this.disposed = true; + const pending = Promise.all(this.pendingMarks) + .catch(() => {}) + .then(() => this.drain().catch(() => {})); + pendingDrains.add(pending); + void pending.finally(() => pendingDrains.delete(pending)); + }), + ); + } + + record(summary: SessionSummary): void { + if (this.disposed) return; + const mark = markSessionDirty(this.storage, this.sessionsScope, summary.id).catch((error) => { + this.log.debug('session index dirty mark failed', { error: String(error) }); + }); + this.pendingMarks.add(mark); + void mark.finally(() => this.pendingMarks.delete(mark)); + if (!databaseBaseEnabled(this.config)) return; + if (this.pendingMap.size >= MAX_PENDING && !this.pendingMap.has(summary.id)) { + if (!this.overflowLogged) { + this.overflowLogged = true; + this.log.warn('session index mirror queue full; dropping summaries until it drains', { + pending: this.pendingMap.size, + }); + } + return; + } + this.overflowLogged = false; + this.pendingMap.set(summary.id, summary); + if (this.pendingMap.size >= FLUSH_BATCH_SIZE) { + void this.flush(); + } else if (!this.timer.isSet()) { + this.timer.cancelAndSet(() => void this.flush(), FLUSH_INTERVAL_MS); + } + } + + pending(): readonly SessionSummary[] { + return [...this.pendingMap.values()]; + } + + async evict(id: string): Promise { + this.pendingMap.delete(id); + await this.flushing; + this.pendingMap.delete(id); + } + + async drain(): Promise { + this.timer.cancel(); + while (this.pendingMap.size > 0) { + const before = this.pendingMap.size; + await this.flush(); + if (this.pendingMap.size >= before) { + this.log.warn('session index mirror drain made no progress; leaving the rest dirty', { + pending: this.pendingMap.size, + }); + return; + } + } + } + + private flush(): Promise { + this.flushing ??= this.flushChunk().finally(() => { + this.flushing = undefined; + if (this.pendingMap.size > 0 && this.consecutiveFailures < MAX_CONSECUTIVE_FAILURES) { + this.timer.cancelAndSet(() => void this.flush(), FLUSH_INTERVAL_MS); + } + }); + return this.flushing; + } + + private async flushChunk(): Promise { + const chunk = [...this.pendingMap.entries()].slice(0, FLUSH_BATCH_SIZE); + if (chunk.length === 0) return; + try { + const manifest = await this.queryStore.getCheckpoint(SESSION_INDEX_MANIFEST); + if (manifest === undefined) { + this.consecutiveFailures += 1; + return; + } + const collection = sessionCollection(manifest.seq); + const counters = sessionCountersCollection(manifest.seq); + const ids = chunk.map(([id]) => id); + const olds = await this.queryStore.getMany(collection, ids); + + const deltas = new Map(); + const bump = (workspaceId: string, field: 'active' | 'archived', by: number): void => { + const entry = deltas.get(workspaceId) ?? { active: 0, archived: 0 }; + entry[field] += by; + deltas.set(workspaceId, entry); + }; + for (const [id, summary] of chunk) { + const old = olds.get(id); + if (old === undefined) { + bump(summary.workspaceId, summary.archived ? 'archived' : 'active', 1); + } else if (old.workspaceId !== summary.workspaceId) { + bump(old.workspaceId, old.archived ? 'archived' : 'active', -1); + bump(summary.workspaceId, summary.archived ? 'archived' : 'active', 1); + } else if (old.archived !== summary.archived) { + bump(summary.workspaceId, old.archived ? 'archived' : 'active', -1); + bump(summary.workspaceId, summary.archived ? 'archived' : 'active', 1); + } + } + + const current = await this.queryStore.getMany(counters, [ + ...deltas.keys(), + ]); + const ops = [ + ...chunk.map(([id, summary]) => ({ + kind: 'put' as const, + collection, + key: id, + value: withRecencyField(manifest.seq, summary), + columns: { [recencyColumn(manifest.seq)]: summary.updatedAt }, + })), + ...[...deltas.entries()].map(([workspaceId, delta]) => { + const base = current.get(workspaceId) ?? { active: 0, archived: 0 }; + const value: SessionWorkspaceCounts = { + active: Math.max(0, base.active + delta.active), + archived: Math.max(0, base.archived + delta.archived), + }; + return { kind: 'put' as const, collection: counters, key: workspaceId, value }; + }), + ]; + await this.queryStore.batch(ops); + for (const [id, summary] of chunk) { + if (this.pendingMap.get(id) === summary) this.pendingMap.delete(id); + } + if (this.consecutiveFailures > 0) { + this.log.info('session index mirror flush recovered', { + afterFailures: this.consecutiveFailures, + pending: this.pendingMap.size, + }); + } + this.consecutiveFailures = 0; + this.giveUpTracked = false; + } catch (error) { + this.consecutiveFailures += 1; + if (this.consecutiveFailures === 1) { + this.log.warn('failed to flush session index mirror chunk', { + pending: this.pendingMap.size, + error: String(error), + }); + } + if (this.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + this.log.warn('session index mirror giving up until the next record; reconciliation will heal', { + pending: this.pendingMap.size, + failures: this.consecutiveFailures, + }); + if (!this.giveUpTracked) { + this.giveUpTracked = true; + this.telemetry.track2('session_index_mirror_give_up', { + pending_count: this.pendingMap.size, + consecutive_failures: this.consecutiveFailures, + }); + } + } + } + } +} + +registerScopedService( + LifecycleScope.App, + ISessionIndexMirror, + SessionIndexMirror, + ScopeActivation.OnScopeCreated, + 'sessionIndex', +); diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexModel.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexModel.ts new file mode 100644 index 0000000000000000000000000000000000000000..e088eddbfc847815b1db01a4a870ddcde8017b49 --- /dev/null +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexModel.ts @@ -0,0 +1,36 @@ +import type { SessionSummary } from './sessionIndex'; + +export const SESSION_INDEX_MANIFEST = 'sessionIndex'; + +export const SESSION_INDEX_SCHEMA_VERSION = 2; + +export const PARENT_INDEX_NAME = 'byParent'; + +export interface SessionWorkspaceCounts { + readonly active: number; + readonly archived: number; +} + +export function sessionCollection(generation: number): string { + return `session:g${generation}`; +} + +export function sessionCountersCollection(generation: number): string { + return `sessionCounters:g${generation}`; +} + +export function recencyColumn(generation: number): string { + return `g${generation}:updatedAt`; +} + +export function withRecencyField(generation: number, summary: SessionSummary): SessionSummary { + return { ...summary, [recencyColumn(generation)]: summary.updatedAt }; +} + +export function stripRecencyField(generation: number, record: SessionSummary): SessionSummary { + const key = recencyColumn(generation); + if (!(key in record)) return record; + const rest: Record = { ...record }; + delete rest[key]; + return rest as unknown as SessionSummary; +} diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexProjector.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexProjector.ts new file mode 100644 index 0000000000000000000000000000000000000000..bb6096e85b8701350045728bc5966bd25695cf89 --- /dev/null +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexProjector.ts @@ -0,0 +1,328 @@ +import { ILogService } from '#/_base/log/log'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { IQueryStore, type WriteOp } from '#/persistence/interface/queryStore'; +import { IFileSystemStorageService } from '#/persistence/interface/storage'; + +import { PARENT_SESSION_ID_KEY, type SessionSummary } from './sessionIndex'; +import { + clearDirtyMarks, + dirtyMarkSessionIds, + listDirtyMarks, +} from './sessionIndexDirtyJournal'; +import { + PARENT_INDEX_NAME, + SESSION_INDEX_MANIFEST, + SESSION_INDEX_SCHEMA_VERSION, + recencyColumn, + sessionCollection, + sessionCountersCollection, + withRecencyField, + type SessionWorkspaceCounts, +} from './sessionIndexModel'; +import { + listSessionIds, + listWorkspaceIds, + mapBounded, + readSessionSummary, + summaryEquals, +} from './sessionIndexSource'; + +const WRITE_CHUNK = 500; +const SCAN_CONCURRENCY = 16; +const SHARED_SCAN_REUSE_MS = 30_000; + +export interface SessionIndexProjectorDeps { + readonly storage: IFileSystemStorageService; + readonly docs: IAtomicDocumentStore; + readonly queryStore: IQueryStore; + readonly log: ILogService; + readonly sessionsScope: string; +} + +export interface ProjectionResult { + readonly generation: number; + readonly sessions: number; +} + +export interface ReconcileResult { + readonly sessions: number; + readonly upserted: number; + readonly removed: number; +} + +export interface AuthoritativeScan { + readonly summaries: SessionSummary[]; + readonly counts: Map; + readonly sourceSessionCount: number; +} + +interface ScanSlot { + readonly promise: Promise; + readonly reusableUntil: number; + settled: boolean; +} + +export class SessionIndexProjector { + private scanSlot: ScanSlot | undefined; + + constructor(private readonly deps: SessionIndexProjectorDeps) {} + + sharedScan(): Promise { + const slot = this.scanSlot; + if (slot !== undefined && (!slot.settled || Date.now() < slot.reusableUntil)) { + return slot.promise; + } + return this.startScan(); + } + + sharedScanForRead(): Promise { + const slot = this.scanSlot; + if (slot !== undefined && !slot.settled) return slot.promise; + return this.startScan(); + } + + private startScan(): Promise { + const slot: ScanSlot = { + promise: this.scanAuthoritative(), + reusableUntil: Date.now() + SHARED_SCAN_REUSE_MS, + settled: false, + }; + const markSettled = (): void => { + slot.settled = true; + }; + void slot.promise.then(markSettled, markSettled); + this.scanSlot = slot; + return slot.promise; + } + + async project(generation: number): Promise { + const scan = this.sharedScan(); + try { + return await this.doProject(generation, scan); + } finally { + if (this.scanSlot?.promise === scan) this.scanSlot = undefined; + } + } + + private async doProject( + generation: number, + scan: Promise, + ): Promise { + const { queryStore, log } = this.deps; + const epoch = queryStore.storeEpoch(); + const collection = sessionCollection(generation); + const counters = sessionCountersCollection(generation); + await queryStore.dropCollection(collection); + await queryStore.dropCollection(counters); + await queryStore.ensureIndex(collection, { + kind: 'value', + name: PARENT_INDEX_NAME, + field: `custom.${PARENT_SESSION_ID_KEY}`, + }); + + const { summaries, counts, sourceSessionCount } = await scan; + await this.batchChunks( + summaries.map((summary) => ({ + kind: 'put' as const, + collection, + key: summary.id, + value: withRecencyField(generation, summary), + columns: { [recencyColumn(generation)]: summary.updatedAt }, + })), + ); + await this.writeCounters(counters, counts); + await queryStore.setCheckpoint( + SESSION_INDEX_MANIFEST, + { + seq: generation, + sourceSessionCount, + schemaVersion: SESSION_INDEX_SCHEMA_VERSION, + }, + epoch, + ); + log.info('session index generation published', { + generation, + sessions: summaries.length, + }); + + if (generation > 1) { + const staleSession = sessionCollection(generation - 1); + const staleCounters = sessionCountersCollection(generation - 1); + void queryStore + .dropCollection(staleSession) + .then(() => queryStore.dropCollection(staleCounters)) + .catch((error) => { + log.warn('failed to drop previous session index generation', { + generation: generation - 1, + error: String(error), + }); + }); + } + return { generation, sessions: summaries.length }; + } + + async reconcile(generation: number): Promise { + const { queryStore, docs, storage, log, sessionsScope } = this.deps; + const epoch = queryStore.storeEpoch(); + const collection = sessionCollection(generation); + const counters = sessionCountersCollection(generation); + const marks = await listDirtyMarks(storage, sessionsScope); + const changed = dirtyMarkSessionIds(marks); + + const workspaceIds = await listWorkspaceIds(storage, sessionsScope); + const authoritative = new Map(); + for (const workspaceId of workspaceIds) { + for (const sessionId of await listSessionIds(storage, sessionsScope, workspaceId)) { + authoritative.set(sessionId, workspaceId); + } + } + const storedKeys = await queryStore.listKeys(collection); + const stored = new Set(storedKeys); + for (const sessionId of authoritative.keys()) { + if (!stored.has(sessionId)) changed.add(sessionId); + } + for (const key of storedKeys) { + if (!authoritative.has(key)) changed.add(key); + } + + const olds = await queryStore.getMany(collection, [...changed]); + const upserts: WriteOp[] = []; + const removals: WriteOp[] = []; + const applied = new Map(); + const removed = new Set(); + await mapBounded([...changed], SCAN_CONCURRENCY, async (sessionId) => { + const workspaceId = authoritative.get(sessionId); + const summary = + workspaceId === undefined + ? undefined + : await readSessionSummary(docs, sessionsScope, workspaceId, sessionId); + if (summary === undefined) { + if (olds.get(sessionId) !== undefined) { + removals.push({ kind: 'delete', collection, key: sessionId }); + removed.add(sessionId); + } + return; + } + applied.set(sessionId, summary); + const existing = olds.get(sessionId); + if (existing === undefined || !summaryEquals(existing, summary)) { + upserts.push({ + kind: 'put', + collection, + key: sessionId, + value: withRecencyField(generation, summary), + columns: { [recencyColumn(generation)]: summary.updatedAt }, + }); + } + }); + + const deltas = new Map(); + const bump = (workspaceId: string, field: 'active' | 'archived', by: number): void => { + const entry = deltas.get(workspaceId) ?? { active: 0, archived: 0 }; + entry[field] += by; + deltas.set(workspaceId, entry); + }; + for (const summary of applied.values()) { + const old = olds.get(summary.id); + if (old === undefined) { + bump(summary.workspaceId, summary.archived ? 'archived' : 'active', 1); + } else if (old.workspaceId !== summary.workspaceId) { + bump(old.workspaceId, old.archived ? 'archived' : 'active', -1); + bump(summary.workspaceId, summary.archived ? 'archived' : 'active', 1); + } else if (old.archived !== summary.archived) { + bump(summary.workspaceId, old.archived ? 'archived' : 'active', -1); + bump(summary.workspaceId, summary.archived ? 'archived' : 'active', 1); + } + } + for (const sessionId of removed) { + const old = olds.get(sessionId); + if (old !== undefined) bump(old.workspaceId, old.archived ? 'archived' : 'active', -1); + } + const current = await queryStore.getMany(counters, [...deltas.keys()]); + const counterOps: WriteOp[] = [...deltas.entries()].map(([workspaceId, delta]) => { + const base = current.get(workspaceId) ?? { active: 0, archived: 0 }; + const value: SessionWorkspaceCounts = { + active: Math.max(0, base.active + delta.active), + archived: Math.max(0, base.archived + delta.archived), + }; + return { kind: 'put', collection: counters, key: workspaceId, value }; + }); + const totals = new Map(); + for (const workspaceId of authoritative.values()) { + totals.set(workspaceId, (totals.get(workspaceId) ?? 0) + 1); + } + for (const key of await queryStore.listKeys(counters)) { + if (!totals.has(key)) counterOps.push({ kind: 'delete', collection: counters, key }); + } + + await this.batchChunks([...upserts, ...removals, ...counterOps]); + const manifest = await queryStore.getCheckpoint(SESSION_INDEX_MANIFEST); + if (manifest?.seq === generation) { + await queryStore.setCheckpoint( + SESSION_INDEX_MANIFEST, + { + seq: generation, + sourceSessionCount: authoritative.size, + schemaVersion: SESSION_INDEX_SCHEMA_VERSION, + }, + epoch, + ); + } + await clearDirtyMarks(storage, sessionsScope, marks); + const result = { + sessions: authoritative.size, + upserted: upserts.length, + removed: removals.length, + }; + if (result.upserted > 0 || result.removed > 0) { + log.info('session index reconciliation repaired drift', { generation, ...result }); + } + return result; + } + + private async scanAuthoritative(): Promise { + const { storage, docs, sessionsScope } = this.deps; + const summaries: SessionSummary[] = []; + const counts = new Map(); + let sourceSessionCount = 0; + for (const workspaceId of await listWorkspaceIds(storage, sessionsScope)) { + const sessionIds = await listSessionIds(storage, sessionsScope, workspaceId); + sourceSessionCount += sessionIds.length; + const found = await mapBounded(sessionIds, SCAN_CONCURRENCY, async (sessionId) => + readSessionSummary(docs, sessionsScope, workspaceId, sessionId), + ); + const entry = counts.get(workspaceId) ?? { active: 0, archived: 0 }; + for (const summary of found) { + summaries.push(summary); + if (summary.archived) entry.archived += 1; + else entry.active += 1; + } + counts.set(workspaceId, entry); + } + return { summaries, counts, sourceSessionCount }; + } + + private async writeCounters( + counters: string, + counts: Map, + ): Promise { + const { queryStore } = this.deps; + const ops: WriteOp[] = [...counts.entries()].map(([workspaceId, value]) => ({ + kind: 'put', + collection: counters, + key: workspaceId, + value: { active: value.active, archived: value.archived } satisfies SessionWorkspaceCounts, + })); + const existing = await queryStore.listKeys(counters); + for (const key of existing) { + if (!counts.has(key)) ops.push({ kind: 'delete', collection: counters, key }); + } + await this.batchChunks(ops); + } + + private async batchChunks(ops: readonly WriteOp[]): Promise { + for (let start = 0; start < ops.length; start += WRITE_CHUNK) { + await this.deps.queryStore.batch(ops.slice(start, start + WRITE_CHUNK)); + } + } +} diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts new file mode 100644 index 0000000000000000000000000000000000000000..45b2b330d4837420bcc2481d15212beb5acc9694 --- /dev/null +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts @@ -0,0 +1,700 @@ +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { IntervalTimer } from '#/_base/utils/timer'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigService } from '#/app/config/config'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import type { SessionIndexDegradedEvent } from '#/app/telemetry/events'; +import { isError2 } from '#/errors'; +import { databaseBaseEnabled } from '#/persistence/configSection'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { + IQueryStore, + type Checkpoint, + type ColumnBounds, + type Page, + type QueryFilter, +} from '#/persistence/interface/queryStore'; +import { IFileSystemStorageService } from '#/persistence/interface/storage'; + +import { + CHILD_SESSION_KIND, + CHILD_SESSION_KIND_KEY, + ISessionIndex, + ISessionIndexMirror, + PARENT_SESSION_ID_KEY, + type SessionCountQuery, + type SessionIndexStatus, + type SessionIndexState, + type SessionListQuery, + type SessionSummary, +} from './sessionIndex'; +import { markSessionDirty } from './sessionIndexDirtyJournal'; +import { + PARENT_INDEX_NAME, + SESSION_INDEX_MANIFEST, + SESSION_INDEX_SCHEMA_VERSION, + recencyColumn, + sessionCollection, + sessionCountersCollection, + stripRecencyField, + type SessionWorkspaceCounts, +} from './sessionIndexModel'; +import { SessionIndexProjector } from './sessionIndexProjector'; +import { + listSessionIds, + listWorkspaceIds, + readSessionSummary, + scanSessionsFreshness, + summaryMatchesChildOf, +} from './sessionIndexSource'; + +const RECONCILE_INTERVAL_MS = 60_000; +const DEGRADED_RETRY_MS = 5_000; +const TIE_REPAIR_LIMIT = 1_000; +const UNBOUNDED = Number.MAX_SAFE_INTEGER; + +function canonicalOrder(a: SessionSummary, b: SessionSummary): number { + if (a.updatedAt !== b.updatedAt) return b.updatedAt - a.updatedAt; + return a.id < b.id ? 1 : a.id > b.id ? -1 : 0; +} + +function isSessionSummaryShape(value: unknown): value is SessionSummary { + if (value === null || typeof value !== 'object') return false; + const summary = value as Record; + return ( + typeof summary['id'] === 'string' && + typeof summary['workspaceId'] === 'string' && + typeof summary['createdAt'] === 'number' && + typeof summary['updatedAt'] === 'number' && + typeof summary['archived'] === 'boolean' + ); +} + +export class FileSessionIndex extends Disposable implements ISessionIndex { + declare readonly _serviceBrand: undefined; + + private state: SessionIndexState = 'uninitialized'; + private generation: number | undefined; + private statusReason: string | undefined; + private degradedCount = 0; + private nextPrepareRetryAt = 0; + private lastDegradedKey: string | undefined; + private prepareFlight: Promise | undefined; + private projectFlight: Promise | undefined; + private readonly reconcileTimer = this._register(new IntervalTimer({ unref: true })); + private readonly projector: SessionIndexProjector; + + constructor( + @IBootstrapService private readonly bootstrap: IBootstrapService, + @IFileSystemStorageService private readonly storage: IFileSystemStorageService, + @IAtomicDocumentStore private readonly docs: IAtomicDocumentStore, + @IQueryStore private readonly queryStore: IQueryStore, + @IConfigService private readonly config: IConfigService, + @ISessionIndexMirror private readonly mirror: ISessionIndexMirror, + @ITelemetryService private readonly telemetry: ITelemetryService, + @ILogService private readonly log: ILogService, + ) { + super(); + this.projector = new SessionIndexProjector({ + storage, + docs, + queryStore, + log, + sessionsScope: bootstrap.scope('sessions'), + }); + } + + private ensureReconcileTimer(): void { + if (!this.reconcileTimer.isSet()) { + this.reconcileTimer.cancelAndSet(() => void this.tick(), RECONCILE_INTERVAL_MS); + } + } + + async prepare(options?: { deadlineMs?: number }): Promise { + if (!this.readModelEnabled()) return this.status(); + this.prepareFlight ??= this.doPrepare(options?.deadlineMs).finally(() => { + this.prepareFlight = undefined; + }); + return this.prepareFlight; + } + + status(): SessionIndexStatus { + return { + state: this.readModelEnabled() ? this.state : 'uninitialized', + generation: this.generation, + reason: this.statusReason, + degradedCount: this.degradedCount, + }; + } + + private async doPrepare(deadlineMs?: number): Promise { + if (this.state === 'ready') return this.status(); + this.state = 'preparing'; + try { + const manifest = await this.queryStore.getCheckpoint(SESSION_INDEX_MANIFEST); + if (manifest === undefined || manifest.schemaVersion !== SESSION_INDEX_SCHEMA_VERSION) { + const projection = this.ensureProjection(); + if (deadlineMs === undefined) { + await projection; + } else { + await Promise.race([ + projection, + new Promise((resolve) => { + setTimeout(resolve, deadlineMs); + }), + ]); + } + } else { + this.generation = manifest.seq; + await this.ensureSchema(manifest.seq); + if (!(await this.manifestFresh(manifest))) { + try { + const reconciliation = this.projector.reconcile(manifest.seq); + if (deadlineMs === undefined) { + await reconciliation; + } else { + await Promise.race([ + reconciliation, + new Promise((resolve) => { + setTimeout(resolve, deadlineMs); + }), + ]); + } + } catch (error) { + const published = await this.queryStore + .getCheckpoint(SESSION_INDEX_MANIFEST) + .catch(() => undefined); + if (published === undefined) throw error; + this.log.warn('session index startup reconciliation failed; serving the published generation', { + error: String(error), + }); + } + } + } + const published = await this.queryStore.getCheckpoint(SESSION_INDEX_MANIFEST); + if (published !== undefined) { + this.generation = published.seq; + this.markReady(); + } + } catch (error) { + this.markDegraded('prepare failed', error); + } + return this.status(); + } + + private async manifestFresh(manifest: Checkpoint): Promise { + if (manifest.sourceSessionCount === undefined) return false; + try { + const scan = await scanSessionsFreshness(this.storage, this.sessionsScope); + return scan.dirtyMarkCount === 0 && scan.sessionCount === manifest.sourceSessionCount; + } catch (error) { + this.log.warn('session index freshness check failed; treating the index as stale', { + error: String(error), + }); + return false; + } + } + + private ensureProjection(): Promise { + this.projectFlight ??= this.runProjection().finally(() => { + this.projectFlight = undefined; + }); + return this.projectFlight; + } + + private async runProjection(): Promise { + const startedAt = Date.now(); + try { + const manifest = await this.queryStore.getCheckpoint(SESSION_INDEX_MANIFEST); + const next = (manifest?.seq ?? 0) + 1; + const result = await this.projector.project(next); + this.generation = result.generation; + this.markReady(); + this.telemetry.track2('session_index_projected', { + duration_ms: Date.now() - startedAt, + session_count: result.sessions, + generation: result.generation, + }); + } catch (error) { + const published = await this.queryStore + .getCheckpoint(SESSION_INDEX_MANIFEST) + .catch(() => undefined); + if (published !== undefined) { + this.generation = published.seq; + this.markReady(); + this.log.warn('session index re-projection failed; staying on the previous generation', { + generation: published.seq, + error: String(error), + }); + } else { + this.markDegraded('projection failed', error); + } + } + } + + async reconcileNow(): Promise { + if (!this.readModelEnabled()) return; + const manifest = await this.queryStore.getCheckpoint(SESSION_INDEX_MANIFEST); + if (manifest === undefined) return; + this.generation = manifest.seq; + await this.projector.reconcile(manifest.seq); + } + + async reprojectNow(): Promise { + if (!this.readModelEnabled()) return; + await this.ensureProjection(); + } + + stopReconcileLoop(): void { + this.reconcileTimer.cancel(); + } + + private async tick(): Promise { + if (!this.readModelEnabled()) return; + if (this.state === 'degraded') { + void this.prepare(); + return; + } + if (this.state !== 'ready') return; + try { + const manifest = await this.queryStore.getCheckpoint(SESSION_INDEX_MANIFEST); + if (manifest === undefined) { + this.markDegraded('published generation lost'); + void this.prepare(); + return; + } + this.generation = manifest.seq; + if (await this.manifestFresh(manifest)) return; + await this.projector.reconcile(manifest.seq); + } catch (error) { + this.log.warn('session index reconciliation failed', { error: String(error) }); + } + } + + private markReady(): void { + if (this.state === 'degraded') { + this.log.info('session index read model recovered', { degradedCount: this.degradedCount }); + } + this.state = 'ready'; + this.statusReason = undefined; + this.lastDegradedKey = undefined; + this.ensureReconcileTimer(); + } + + private markDegraded(reason: string, error?: unknown): void { + this.state = 'degraded'; + this.statusReason = reason; + this.degradedCount += 1; + this.nextPrepareRetryAt = Date.now() + DEGRADED_RETRY_MS; + this.ensureReconcileTimer(); + const detail = + error instanceof Error ? error.message : typeof error === 'string' ? error : undefined; + const episodeKey = `${reason}:${detail ?? ''}`; + if (episodeKey === this.lastDegradedKey) return; + this.lastDegradedKey = episodeKey; + this.log.warn('session index read model degraded; serving authoritative reads', { + reason, + ...(detail !== undefined ? { error: detail } : {}), + degradedCount: this.degradedCount, + }); + const properties: SessionIndexDegradedEvent = { + reason, + degraded_count: this.degradedCount, + }; + if (error !== undefined) { + properties.error_type = isError2(error) + ? error.code + : error instanceof Error + ? error.name + : 'Unknown'; + } + this.telemetry.track2('session_index_degraded', properties); + } + + private async ensureSchema(generation: number): Promise { + await this.queryStore.ensureIndex(sessionCollection(generation), { + kind: 'value', + name: PARENT_INDEX_NAME, + field: `custom.${PARENT_SESSION_ID_KEY}`, + }); + } + + async get(id: string): Promise { + return this.withReadModel( + (generation) => this.getFromReadModel(generation, id), + () => this.getLegacy(id), + ); + } + + async listRecent(query: SessionListQuery): Promise> { + return this.withReadModel( + (generation) => this.listRecentFromReadModel(generation, query), + () => this.listLegacy(query), + ); + } + + async count(query: SessionCountQuery): Promise { + return this.withReadModel( + (generation) => this.countFromReadModel(generation, query), + () => this.countLegacy(query), + ); + } + + async remove(id: string): Promise { + await this.mirror.evict(id); + await this.withReadModel( + async (generation) => { + await this.queryStore.delete(sessionCollection(generation), id); + }, + () => Promise.resolve(), + ); + try { + await markSessionDirty(this.storage, this.sessionsScope, id); + } catch (error) { + this.log.warn('session index dirty mark failed', { error: String(error) }); + } + } + + private async withReadModel( + op: (generation: number) => Promise, + legacy: () => Promise, + ): Promise { + if (!this.readModelEnabled()) return legacy(); + if (this.state === 'uninitialized') { + void this.prepare(); + return legacy(); + } + if (this.state === 'preparing') return legacy(); + if (this.state === 'degraded') { + if (Date.now() >= this.nextPrepareRetryAt) void this.prepare(); + return legacy(); + } + let manifest: Checkpoint | undefined; + try { + manifest = await this.queryStore.getCheckpoint(SESSION_INDEX_MANIFEST); + } catch (error) { + this.markDegraded('read model read failed', error); + return legacy(); + } + if (manifest === undefined) { + this.markDegraded('published generation lost'); + void this.prepare(); + return legacy(); + } + this.generation = manifest.seq; + try { + return await op(manifest.seq); + } catch (error) { + this.markDegraded('read model read failed', error); + return legacy(); + } + } + + private async getFromReadModel( + generation: number, + id: string, + ): Promise { + const queued = this.mirror.pending().find((summary) => summary.id === id); + if (queued !== undefined) return queued; + const cached: unknown = await this.queryStore.get(sessionCollection(generation), id); + if (isSessionSummaryShape(cached)) return stripRecencyField(generation, cached); + const summary = await this.getLegacy(id); + if (summary !== undefined) this.mirror.record(summary); + return summary; + } + + private async listRecentFromReadModel( + generation: number, + query: SessionListQuery, + ): Promise> { + const collection = sessionCollection(generation); + if (query.sessionId !== undefined) { + const summary = await this.getFromReadModel(generation, query.sessionId); + const items = + summary !== undefined && (!summary.archived || query.includeArchived === true) + ? [summary] + : []; + return { items: query.limit !== undefined ? items.slice(0, query.limit) : items }; + } + + const cursor = await this.resolveCursor(generation, query); + if (cursor === undefined) return { items: [] }; + const limit = query.limit ?? UNBOUNDED; + const filter = { ...this.baseFilter(query), ...cursor.filter }; + const column = recencyColumn(generation); + const strip = (records: SessionSummary[]): SessionSummary[] => + records.map((record) => stripRecencyField(generation, record)); + + const page = + query.childOf !== undefined + ? await this.windowedPage( + (bounds, fetchLimit) => { + const base = this.queryStore + .query(collection) + .where(filter) + .orderBy('updatedAt', 'desc') + .limit(fetchLimit); + const q = + Object.keys(bounds).length > 0 ? base.whereColumn(column, bounds) : base; + return q.execute().then((p) => strip([...p.items])); + }, + cursor.bounds, + limit, + ) + : await this.windowedPage( + (bounds, fetchLimit) => + this.queryStore + .pageByColumn(collection, { + column, + dir: 'desc', + filter, + bounds, + limit: fetchLimit, + }) + .then((p) => strip([...p.items])), + cursor.bounds, + limit, + ); + return this.mergePending(page, query, cursor.position); + } + + private async countFromReadModel( + generation: number, + query: SessionCountQuery, + ): Promise { + const counters = sessionCountersCollection(generation); + const restricted = query.workspaceIds; + const workspaceIds = restricted ?? (await this.queryStore.listKeys(counters)); + const counts = await this.queryStore.getMany(counters, workspaceIds); + let total = 0; + for (const entry of counts.values()) { + total += query.includeArchived === true ? entry.active + entry.archived : entry.active; + } + const pending = this.mirror + .pending() + .filter((summary) => restricted === undefined || restricted.includes(summary.workspaceId)); + if (pending.length === 0) return total; + const stored = await this.queryStore.getMany( + sessionCollection(generation), + pending.map((summary) => summary.id), + ); + const weight = (archived: boolean): number => + query.includeArchived === true || !archived ? 1 : 0; + for (const summary of pending) { + const old = stored.get(summary.id); + total += weight(summary.archived) - (old === undefined ? 0 : weight(old.archived)); + } + return total; + } + + private async windowedPage( + fetch: (bounds: ColumnBounds, limit: number) => Promise, + bounds: ColumnBounds, + limit: number, + ): Promise> { + const raw = await fetch(bounds, limit + 1); + if (raw.length <= limit) { + return { items: raw.toSorted(canonicalOrder) }; + } + const minUpdatedAt = Math.min(...raw.map((summary) => summary.updatedAt)); + const tie = await fetch({ gte: minUpdatedAt, lte: minUpdatedAt }, TIE_REPAIR_LIMIT); + const merged = new Map(); + for (const summary of tie) merged.set(summary.id, summary); + for (const summary of raw) merged.set(summary.id, summary); + const items = [...merged.values()].toSorted(canonicalOrder); + const kept = items.slice(0, limit); + const hasMore = items.length > limit || tie.length >= TIE_REPAIR_LIMIT; + return { items: kept, nextCursor: hasMore ? kept.at(-1)!.id : undefined }; + } + + private mergePending( + page: Page, + query: SessionListQuery, + position?: { u: number; id: string; before: boolean }, + ): Page { + const pending = this.mirror + .pending() + .filter( + (summary) => + (query.workspaceIds === undefined || query.workspaceIds.includes(summary.workspaceId)) && + (query.includeArchived === true || !summary.archived) && + summaryMatchesChildOf(summary, query.childOf) && + (position === undefined || + (position.before + ? summary.updatedAt < position.u || + (summary.updatedAt === position.u && summary.id < position.id) + : summary.updatedAt > position.u || + (summary.updatedAt === position.u && summary.id > position.id))), + ); + if (pending.length === 0) return page; + const merged = new Map(); + for (const summary of pending) merged.set(summary.id, summary); + for (const summary of page.items) { + if (!merged.has(summary.id)) merged.set(summary.id, summary); + } + const items = [...merged.values()].toSorted(canonicalOrder); + if (query.limit === undefined) return { items }; + const kept = items.slice(0, query.limit); + const hasMore = page.nextCursor !== undefined || items.length > query.limit; + return { items: kept, nextCursor: hasMore ? kept.at(-1)!.id : undefined }; + } + + private async resolveCursor( + generation: number, + query: SessionListQuery, + ): Promise< + | { filter: QueryFilter; bounds: ColumnBounds; position?: { u: number; id: string; before: boolean } } + | undefined + > { + const id = query.before ?? query.after; + if (id === undefined) return { filter: {}, bounds: {} }; + const storedValue: unknown = await this.queryStore.get(sessionCollection(generation), id); + const stored = isSessionSummaryShape(storedValue) ? storedValue : undefined; + const cursor = stored ?? this.mirror.pending().find((summary) => summary.id === id); + if (cursor === undefined) return undefined; + const u = cursor.updatedAt; + if (query.before !== undefined) { + return { + bounds: { lte: u }, + filter: { + $or: [ + { updatedAt: { $lt: u } }, + { updatedAt: u, id: { $lt: id } }, + ], + }, + position: { u, id, before: true }, + }; + } + return { + bounds: { gte: u }, + filter: { + $or: [ + { updatedAt: { $gt: u } }, + { updatedAt: u, id: { $gt: id } }, + ], + }, + position: { u, id, before: false }, + }; + } + + private baseFilter(query: SessionListQuery): QueryFilter { + const filter: Record = {}; + if (query.workspaceIds !== undefined) { + filter['workspaceId'] = + query.workspaceIds.length === 1 + ? query.workspaceIds[0] + : { $in: [...query.workspaceIds] }; + } + if (query.childOf !== undefined) { + filter[`custom.${PARENT_SESSION_ID_KEY}`] = query.childOf; + filter[`custom.${CHILD_SESSION_KIND_KEY}`] = CHILD_SESSION_KIND; + } + if (query.includeArchived !== true) filter['archived'] = { $ne: true }; + return filter; + } + + private get sessionsScope(): string { + return this.bootstrap.scope('sessions'); + } + + private async listLegacy(query: SessionListQuery): Promise> { + if (query.sessionId !== undefined) { + const summary = await this.getLegacy(query.sessionId); + const items = + summary !== undefined && (!summary.archived || query.includeArchived === true) + ? [summary] + : []; + return { items: query.limit !== undefined ? items.slice(0, query.limit) : items }; + } + + const collected = (await this.collectAuthoritative(query.workspaceIds)).filter( + (summary) => + (query.includeArchived === true || !summary.archived) && + summaryMatchesChildOf(summary, query.childOf), + ); + const items = collected.toSorted(canonicalOrder); + + let start = 0; + let end = items.length; + const cursorId = query.before ?? query.after; + if (cursorId !== undefined) { + const index = items.findIndex((summary) => summary.id === cursorId); + if (index === -1) return { items: [] }; + if (query.before !== undefined) start = index + 1; + else end = index; + } + const window = items.slice(start, end); + if (query.limit === undefined) return { items: window }; + const kept = window.slice(0, query.limit); + return { + items: kept, + nextCursor: window.length > query.limit ? kept.at(-1)!.id : undefined, + }; + } + + private async getLegacy(id: string): Promise { + for (const workspaceId of await listWorkspaceIds(this.storage, this.sessionsScope)) { + const sessionIds = await listSessionIds(this.storage, this.sessionsScope, workspaceId); + if (!sessionIds.includes(id)) continue; + const summary = await readSessionSummary(this.docs, this.sessionsScope, workspaceId, id); + if (summary !== undefined) return summary; + } + return undefined; + } + + private async countLegacy(query: SessionCountQuery): Promise { + let count = 0; + for (const summary of await this.collectAuthoritative(query.workspaceIds)) { + if (query.includeArchived === true || !summary.archived) count += 1; + } + return count; + } + + private async collectAuthoritative( + workspaceIds: readonly string[] | undefined, + ): Promise { + let collected: SessionSummary[]; + if ( + this.readModelEnabled() && + (this.state === 'uninitialized' || this.state === 'preparing') + ) { + const { summaries } = await this.projector.sharedScanForRead(); + collected = + workspaceIds === undefined + ? summaries + : summaries.filter((summary) => workspaceIds.includes(summary.workspaceId)); + } else { + const ids = workspaceIds ?? (await listWorkspaceIds(this.storage, this.sessionsScope)); + collected = []; + for (const workspaceId of ids) { + for (const sessionId of await listSessionIds(this.storage, this.sessionsScope, workspaceId)) { + const summary = await readSessionSummary(this.docs, this.sessionsScope, workspaceId, sessionId); + if (summary !== undefined) collected.push(summary); + } + } + } + const pending = this.mirror.pending(); + if (pending.length === 0) return collected; + const byId = new Map(collected.map((summary) => [summary.id, summary])); + for (const summary of pending) { + if (workspaceIds !== undefined && !workspaceIds.includes(summary.workspaceId)) continue; + byId.set(summary.id, summary); + } + return [...byId.values()]; + } + + private readModelEnabled(): boolean { + return databaseBaseEnabled(this.config); + } +} + +registerScopedService( + LifecycleScope.App, + ISessionIndex, + FileSessionIndex, + ScopeActivation.OnScopeCreated, + 'sessionIndex', +); diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts new file mode 100644 index 0000000000000000000000000000000000000000..adde8cd41a1340f6eebc048bcd6a8ca149be515c --- /dev/null +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts @@ -0,0 +1,191 @@ +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { IFileSystemStorageService } from '#/persistence/interface/storage'; + +import { CHILD_SESSION_KIND, CHILD_SESSION_KIND_KEY, type SessionSummary } from './sessionIndex'; +import { SESSION_INDEX_DIRTY_DIR, listDirtyMarks } from './sessionIndexDirtyJournal'; + +const META_SCOPE = 'session-meta'; +const META_KEY = 'state.json'; + +export function parseTime(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const parsed = Date.parse(value); + if (!Number.isNaN(parsed)) return parsed; + } + return 0; +} + +export function parseTurnOutcome(value: unknown): 'completed' | 'cancelled' | 'failed' | undefined { + return value === 'completed' || value === 'cancelled' || value === 'failed' ? value : undefined; +} + +export function recoverCwd(meta: Record): string | undefined { + if (typeof meta['cwd'] === 'string' && meta['cwd'].length > 0) return meta['cwd']; + if (typeof meta['workDir'] === 'string' && meta['workDir'].length > 0) { + return meta['workDir']; + } + const custom = meta['custom']; + if (custom !== null && typeof custom === 'object' && !Array.isArray(custom)) { + const fromCustom = (custom as Record)['cwd']; + if (typeof fromCustom === 'string' && fromCustom.length > 0) return fromCustom; + } + return undefined; +} + +export function buildSessionSummary(fields: { + id: string; + workspaceId: string; + cwd?: string; + title?: string; + lastPrompt?: string; + createdAt: number; + updatedAt: number; + archived: boolean; + archivedAt?: number; + custom?: Record; + lastTurnReason?: 'completed' | 'cancelled' | 'failed'; +}): SessionSummary { + return { + id: fields.id, + workspaceId: fields.workspaceId, + cwd: fields.cwd, + title: fields.title, + lastPrompt: fields.lastPrompt, + createdAt: fields.createdAt, + updatedAt: fields.updatedAt, + archived: fields.archived, + archivedAt: fields.archivedAt, + custom: fields.custom, + lastTurnReason: fields.lastTurnReason, + }; +} + +export function summaryMatchesChildOf( + summary: SessionSummary, + parentId: string | undefined, +): boolean { + if (parentId === undefined) return true; + const custom = summary.custom; + return ( + custom?.['parent_session_id'] === parentId && + custom?.[CHILD_SESSION_KIND_KEY] === CHILD_SESSION_KIND + ); +} + +export function summaryEquals(a: SessionSummary, b: SessionSummary): boolean { + return ( + a.id === b.id && + a.workspaceId === b.workspaceId && + a.cwd === b.cwd && + a.title === b.title && + a.lastPrompt === b.lastPrompt && + a.createdAt === b.createdAt && + a.updatedAt === b.updatedAt && + a.archived === b.archived && + a.archivedAt === b.archivedAt && + a.lastTurnReason === b.lastTurnReason && + JSON.stringify(a.custom) === JSON.stringify(b.custom) + ); +} + +export async function listWorkspaceIds( + storage: IFileSystemStorageService, + sessionsScope: string, +): Promise { + try { + return (await storage.list(sessionsScope)).filter((entry) => entry !== SESSION_INDEX_DIRTY_DIR); + } catch { + return []; + } +} + +export async function listSessionIds( + storage: IFileSystemStorageService, + sessionsScope: string, + workspaceId: string, +): Promise { + try { + return await storage.list(`${sessionsScope}/${workspaceId}`); + } catch { + return []; + } +} + +export async function readSessionSummary( + docs: IAtomicDocumentStore, + sessionsScope: string, + workspaceId: string, + sessionId: string, +): Promise { + const base = `${sessionsScope}/${workspaceId}/${sessionId}`; + const meta = (await readMeta(docs, base)) ?? (await readMeta(docs, `${base}/${META_SCOPE}`)); + if (meta === undefined) return undefined; + const rawCustom = meta['custom']; + const custom = + rawCustom !== null && typeof rawCustom === 'object' && !Array.isArray(rawCustom) + ? (rawCustom as Record) + : undefined; + return buildSessionSummary({ + id: sessionId, + workspaceId, + cwd: recoverCwd(meta), + title: typeof meta['title'] === 'string' ? meta['title'] : undefined, + lastPrompt: typeof meta['lastPrompt'] === 'string' ? meta['lastPrompt'] : undefined, + createdAt: parseTime(meta['createdAt']), + updatedAt: parseTime(meta['updatedAt']), + archived: meta['archived'] === true, + archivedAt: meta['archivedAt'] === undefined ? undefined : parseTime(meta['archivedAt']), + custom, + lastTurnReason: parseTurnOutcome(meta['lastTurnReason']), + }); +} + +async function readMeta( + docs: IAtomicDocumentStore, + scope: string, +): Promise | undefined> { + try { + return await docs.get>(scope, META_KEY); + } catch { + return undefined; + } +} + +export async function mapBounded( + items: readonly T[], + concurrency: number, + fn: (item: T) => Promise, +): Promise { + const out: R[] = []; + let next = 0; + const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => { + while (next < items.length) { + const item = items[next++]!; + const value = await fn(item); + if (value !== undefined) out.push(value); + } + }); + await Promise.all(workers); + return out; +} + +export interface SessionsFreshness { + readonly dirtyMarkCount: number; + readonly sessionCount: number; +} + +export async function scanSessionsFreshness( + storage: IFileSystemStorageService, + sessionsScope: string, +): Promise { + const [marks, workspaceIds] = await Promise.all([ + listDirtyMarks(storage, sessionsScope), + listWorkspaceIds(storage, sessionsScope), + ]); + let sessionCount = 0; + for (const workspaceId of workspaceIds) { + sessionCount += (await listSessionIds(storage, sessionsScope, workspaceId)).length; + } + return { dirtyMarkCount: marks.length, sessionCount }; +} diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts new file mode 100644 index 0000000000000000000000000000000000000000..71b34ae5326dcbe8aef8028e2aa76b0c157e1450 --- /dev/null +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts @@ -0,0 +1,28 @@ +import type { GoalSnapshot } from '#/features/goal/types'; + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +import type { SessionStatusResponse } from './sessionProtocol'; + +export interface SessionWireFields { + readonly id: string; + readonly workspaceId: string; + readonly root: string; + readonly title?: string; + readonly lastPrompt?: string; + readonly createdAt: number; + readonly updatedAt: number; + readonly archived: boolean; + readonly archivedAt?: number; + readonly custom?: Record; +} + +export interface ISessionLegacyService { + readonly _serviceBrand: undefined; + + status(sessionId: string): Promise; + goal(sessionId: string): Promise; +} + +export const ISessionLegacyService: ServiceIdentifier = + createDecorator('sessionLegacyService'); diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts new file mode 100644 index 0000000000000000000000000000000000000000..3efbe67b910826af80c7017afa68e279ae1416eb --- /dev/null +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts @@ -0,0 +1,143 @@ +import type { GoalSnapshot } from '#/features/goal/types'; + +import type { SessionStatusResponse } from './sessionProtocol'; +import { LifecycleScope } from '#/app/scopes'; +import { + type IAgentScopeHandle, + type ISessionScopeHandle, + ScopeActivation, + registerScopedService, +} from '#/_base/di/scope'; +import { + IInstantiationService, + type ServicesAccessor, +} from '#/_base/di/instantiation'; +import { ISessionTokenCountingService } from '#/session/tokenCounting/sessionTokenCounting'; +import { IAgentGoalService } from '#/features/goal/goalService'; +import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; +import { IAgentPlanService } from '#/features/plan/plan'; +import { IAgentProfileService } from '#/agent/profile/profile'; +import { IAgentSwarmService } from '#/features/swarm/agent/swarm'; +import { IAgentTowerService } from '#/features/tower/tower'; +import { agentContextOf } from '#/agent/scopeContext/scopeContext'; +import { + getLiveSessionById, + resumeSessionById, +} from '#/app/sessionManager/sessionLookup'; +import { IModelCatalog } from '#/llm-adapter/model/catalog'; +import { IModelService } from '#/llm-adapter/model/model'; +import { ErrorCodes, Error2 } from '#/errors'; +import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import { IAgentLoopService } from '#/agent/loop/loop'; +import { IAgentTaskService } from '#/agent/task/task'; +import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; + +import { ISessionLegacyService } from './sessionLegacy'; + +export class SessionLegacyService implements ISessionLegacyService { + declare readonly _serviceBrand: undefined; + + private readonly services: ServicesAccessor; + + constructor(@IInstantiationService instantiation: IInstantiationService) { + this.services = { + get: (id) => instantiation.invokeFunction((accessor) => accessor.get(id)), + }; + } + + private resume(sessionId: string): Promise { + return resumeSessionById(this.services, sessionId); + } + + private async resolveMainAgent(sessionId: string): Promise { + const session = await this.resume(sessionId); + if (session === undefined) { + throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); + } + const context = await ensureMainAgent(session); + const handle = session.accessor.get(IAgentLifecycleService).handleOf(context.agentId); + if (handle === undefined) { + throw new Error2(ErrorCodes.AGENT_NOT_FOUND, 'Main agent was not found'); + } + return handle; + } + + async status(sessionId: string): Promise { + const agent = await this.resolveMainAgent(sessionId); + return this.assembleStatus(sessionId, agent); + } + + private async assembleStatus( + sessionId: string, + agent: IAgentScopeHandle, + ): Promise { + const profile = agent.accessor.get(IAgentProfileService); + const tokenCounting = agent.accessor.get(ISessionTokenCountingService); + const permission = agent.accessor.get(IAgentPermissionModeService); + const plan = agent.accessor.get(IAgentPlanService); + const swarm = agent.accessor.get(IAgentSwarmService); + const tower = agent.accessor.get(IAgentTowerService); + + const model = profile.getModel(); + const capabilities = profile.getModelCapabilities(); + let maxTokens = capabilities.max_input_tokens ?? capabilities.max_context_tokens; + if (maxTokens === 0 && model === '') { + maxTokens = resolveDefaultModelContextTokens(agent) ?? 0; + } + const tokens = tokenCounting.statusSize(agentContextOf(agent)); + const planData = await plan.status(); + + return { + busy: this.readBusy(sessionId), + model: model === '' ? undefined : model, + thinking_level: model === '' ? '' : profile.getEffectiveThinkingLevel(), + permission: permission.mode, + plan_mode: planData !== null, + swarm_mode: swarm.isActive, + tower_mode: tower.isActive, + context_tokens: tokens, + max_context_tokens: maxTokens > 0 ? maxTokens : undefined, + context_usage: maxTokens > 0 ? Math.min(1, tokens / maxTokens) : undefined, + }; + } + + private readBusy(sessionId: string): boolean { + const handle = getLiveSessionById(this.services, sessionId); + if (handle === undefined) return false; + const agents = handle.accessor.get(IAgentLifecycleService); + for (const agent of agents.list()) { + const agentHandle = agents.handleOf(agent.agentId); + if (agentHandle === undefined) continue; + if (agentHandle.accessor.get(IAgentLoopService).snapshot().state === 'running') return true; + const tasks = agentHandle.accessor.get(IAgentTaskService); + if (tasks.list(true).length > 0) return true; + if (agentHandle.accessor.get(IAgentFullCompactionService).compacting !== null) return true; + } + return false; + } + + async goal(sessionId: string): Promise { + const agent = await this.resolveMainAgent(sessionId); + return agent.accessor.get(IAgentGoalService).getGoal().goal; + } +} + +function resolveDefaultModelContextTokens(agent: IAgentScopeHandle): number | undefined { + const defaultModel = agent.accessor.get(IModelService).getDefaultModel(); + if (defaultModel === undefined || defaultModel.length === 0) return undefined; + try { + const capabilities = agent.accessor.get(IModelCatalog).get(defaultModel).capabilities; + return capabilities.max_input_tokens ?? capabilities.max_context_tokens; + } catch { + return undefined; + } +} + +registerScopedService( + LifecycleScope.App, + ISessionLegacyService, + SessionLegacyService, + ScopeActivation.OnScopeCreated, + 'sessionLegacy', +); diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionProtocol.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionProtocol.ts new file mode 100644 index 0000000000000000000000000000000000000000..db80227ddb82fcd9b8e1c317420b849b78d8dd4b --- /dev/null +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionProtocol.ts @@ -0,0 +1,139 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema } from '#/_base/utils/isoDateTime'; +import type { SessionPendingInteraction, SessionTurnOutcome } from '#/session/sessionActivity/sessionActivity'; + +export const sessionWarningSchema = z.object({ + code: z.string(), + message: z.string(), + severity: z.enum(['info', 'warning', 'error']), +}); +export type SessionWarning = z.infer; + +export const sessionWarningsResponseSchema = z.object({ + warnings: z.array(sessionWarningSchema), +}); +export type SessionWarningsResponse = z.infer; + +export const promptThinkingSchema = z.string().min(1); +export type PromptThinking = z.infer; + +export const promptPermissionModeSchema = z.enum(['manual', 'yolo', 'auto']); +export type PromptPermissionMode = z.infer; + +export const sessionMetadataSchema = z + .object({ + cwd: z.string().min(1), + }) + .catchall(z.unknown()); +export type SessionMetadata = z.infer; + +export const sessionAgentConfigSchema = z.object({ + model: z.string(), + system_prompt: z.string().optional(), + tools: z.array(z.string()).optional(), + mcp_servers: z.array(z.string()).optional(), + thinking: promptThinkingSchema.optional(), + permission_mode: promptPermissionModeSchema.optional(), + plan_mode: z.boolean().optional(), + swarm_mode: z.boolean().optional(), + tower_mode: z.boolean().optional(), + tower_base: z.string().min(1).optional(), + goal_objective: z.string().optional(), + goal_control: z.enum(['pause', 'resume', 'cancel']).optional(), +}); +export type SessionAgentConfig = z.infer; + +export const sessionAgentConfigPartialSchema = sessionAgentConfigSchema.partial(); +export type SessionAgentConfigPartial = z.infer; + +export const permissionRuleMatcherSchema = z.object({ + kind: z.enum(['command_prefix', 'path_glob', 'exact_input', 'always']), + value: z.string().optional(), +}); +export type PermissionRuleMatcher = z.infer; + +export const permissionRuleSchema = z.object({ + id: z.string().min(1), + tool_name: z.string().min(1), + matcher: permissionRuleMatcherSchema.optional(), + decision: z.literal('approved'), + created_at: isoDateTimeSchema, + created_by: z.enum(['user', 'agent']), +}); +export type PermissionRule = z.infer; + +export const updateSessionProfileRequestSchema = z.object({ + title: z.string().min(1).optional(), + metadata: sessionMetadataSchema.partial().optional(), + agent_config: sessionAgentConfigPartialSchema.optional(), + permission_rules: z.array(permissionRuleSchema).optional(), +}); +export type UpdateSessionProfileRequest = z.infer; + +export const sessionStatusResponseSchema = z.object({ + busy: z.boolean(), + model: z.string().optional(), + thinking_level: z.string(), + permission: z.string(), + plan_mode: z.boolean(), + swarm_mode: z.boolean(), + tower_mode: z.boolean().optional(), + context_tokens: z.number().int().nonnegative(), + max_context_tokens: z.number().int().nonnegative().optional(), + context_usage: z.number().min(0).max(1).optional(), +}); +export type SessionStatusResponse = z.infer; + +export interface SessionUsage { + input_tokens: number; + output_tokens: number; + cache_read_tokens: number; + cache_creation_tokens: number; + total_cost_usd?: number; + context_tokens: number; + context_limit?: number; + turn_count?: number; +} + +export interface Session { + id: string; + workspace_id: string; + title: string; + created_at: string; + updated_at: string; + busy: boolean; + main_turn_active?: boolean; + pending_interaction?: SessionPendingInteraction; + last_turn_reason?: SessionTurnOutcome; + archived?: boolean; + archived_at?: string; + current_prompt_id?: string; + last_prompt?: string; + metadata: SessionMetadata; + agent_config: SessionAgentConfig; + usage: SessionUsage; + permission_rules: PermissionRule[]; + message_count: number; + last_seq: number; +} + +export interface SessionCreatedEvent { + readonly type: 'event.session.created'; + readonly session: Session; +} + +export interface SessionStatusChangedEvent { + readonly type: 'event.session.status_changed'; + readonly status: 'idle' | 'running' | 'awaiting_approval' | 'awaiting_question' | 'aborted'; + readonly previous_status: 'idle' | 'running' | 'awaiting_approval' | 'awaiting_question' | 'aborted'; + readonly current_prompt_id?: string; +} + +export interface SessionWorkChangedEvent { + readonly type: 'event.session.work_changed'; + readonly busy: boolean; + readonly main_turn_active?: boolean; + readonly pending_interaction?: SessionPendingInteraction; + readonly last_turn_reason?: SessionTurnOutcome; +} diff --git a/packages/agent-core-v2/src/app/state/appState.ts b/packages/agent-core-v2/src/app/state/appState.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc938ad51dbb0caca39650df49046fffad5af4d5 --- /dev/null +++ b/packages/agent-core-v2/src/app/state/appState.ts @@ -0,0 +1,9 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { IStateRegistry } from '#/_base/state/stateRegistry'; + +export interface IAppStateService extends IStateRegistry { + readonly _serviceBrand: undefined; +} + +export const IAppStateService: ServiceIdentifier = + createDecorator('appStateService'); diff --git a/packages/agent-core-v2/src/app/state/appStateService.ts b/packages/agent-core-v2/src/app/state/appStateService.ts new file mode 100644 index 0000000000000000000000000000000000000000..466fae5cf8534256534d00e3ee23664d2f7ec9d4 --- /dev/null +++ b/packages/agent-core-v2/src/app/state/appStateService.ts @@ -0,0 +1,19 @@ +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { StateRegistry } from '#/_base/state/stateRegistry'; + +import { IAppStateService } from './appState'; + +export class AppStateService extends StateRegistry implements IAppStateService { + declare readonly _serviceBrand: undefined; + protected override readonly inspectScope = 'app'; +} + +registerScopedService( + LifecycleScope.App, + IAppStateService, + AppStateService, + ScopeActivation.OnScopeCreated, + 'state', +); diff --git a/packages/agent-core-v2/src/app/web/errors.ts b/packages/agent-core-v2/src/app/web/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..0698c17b0ea272da37ef78ae3e36423d709461ba --- /dev/null +++ b/packages/agent-core-v2/src/app/web/errors.ts @@ -0,0 +1,12 @@ +import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; + +export const WebErrors = { + codes: { + WEB_INVALID_URL: 'web.invalid_url', + WEB_PRIVATE_ADDRESS: 'web.private_address', + WEB_FETCH_FAILED: 'web.fetch_failed', + }, + retryable: ['web.fetch_failed'], +} as const satisfies ErrorDomain; + +registerErrorDomain(WebErrors); diff --git a/packages/agent-core-v2/src/app/web/web.ts b/packages/agent-core-v2/src/app/web/web.ts new file mode 100644 index 0000000000000000000000000000000000000000..a4eb98951d8da2f5492e5a41cbdccfa0eff63525 --- /dev/null +++ b/packages/agent-core-v2/src/app/web/web.ts @@ -0,0 +1,15 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +import type { UrlFetcher } from './tools/fetch-url-types'; + +export type { UrlFetcher, UrlFetchKind, UrlFetchResult } from './tools/fetch-url-types'; +export { HttpFetchError } from './tools/fetch-url-types'; + +export interface IWebFetchService { + readonly _serviceBrand: undefined; + + getUrlFetcher(): UrlFetcher; +} + +export const IWebFetchService: ServiceIdentifier = + createDecorator('webFetchService'); diff --git a/packages/agent-core-v2/src/app/web/webService.ts b/packages/agent-core-v2/src/app/web/webService.ts new file mode 100644 index 0000000000000000000000000000000000000000..350dcfbc905a21fa2e90043ab423c2c7d7977ff0 --- /dev/null +++ b/packages/agent-core-v2/src/app/web/webService.ts @@ -0,0 +1,95 @@ +import { + KIMI_CODE_PROVIDER_NAME, + kimiCodeBaseUrl, +} from '@moonshot-ai/kimi-code-oauth'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IOAuthService } from '#/app/auth/auth'; +import { SERVICES_SECTION, type ServicesConfig } from '#/app/auth/configSection'; +import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigService } from '#/app/config/config'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { IProviderService } from '#/llm-adapter/provider/provider'; +import { isOAuthCatalogVendor } from '#/llm-adapter/provider/provider-definition'; + +import { LocalFetchURLProvider } from './providers/local-fetch-url'; +import { MoonshotFetchURLProvider } from './providers/moonshot-fetch-url'; +import type { UrlFetcher } from './tools/fetch-url-types'; +import { IWebFetchService } from './web'; + +export class WebFetchService implements IWebFetchService { + declare readonly _serviceBrand: undefined; + private readonly localFetcher: UrlFetcher; + + constructor( + @IProviderService private readonly providers: IProviderService, + @IOAuthService private readonly oauth: IOAuthService, + @IBootstrapService private readonly bootstrap: IBootstrapService, + @IConfigService private readonly config: IConfigService, + @IAgentIdentity private readonly identity: IAgentIdentity, + @ITelemetryService private readonly telemetry: ITelemetryService, + ) { + this.localFetcher = new LocalFetchURLProvider(); + } + + getUrlFetcher(): UrlFetcher { + return this.fromServicesConfig() ?? this.fromManagedOAuth() ?? this.localFetcher; + } + + private fromServicesConfig(): UrlFetcher | undefined { + const fetchConfig = this.config.get(SERVICES_SECTION)?.moonshotFetch; + if (fetchConfig?.baseUrl === undefined) { + return undefined; + } + const tokenProvider = + fetchConfig.oauth === undefined + ? undefined + : this.oauth.resolveTokenProvider(KIMI_CODE_PROVIDER_NAME, fetchConfig.oauth); + return new MoonshotFetchURLProvider({ + baseUrl: fetchConfig.baseUrl, + tokenProvider, + apiKey: nonEmptyString(fetchConfig.apiKey), + defaultHeaders: { ...this.identity.current().requestHeaders }, + customHeaders: fetchConfig.customHeaders, + localFallback: this.localFetcher, + telemetry: this.telemetry, + }); + } + + private fromManagedOAuth(): UrlFetcher | undefined { + const provider = this.providers.get(KIMI_CODE_PROVIDER_NAME); + if (provider === undefined || !isOAuthCatalogVendor(provider.type) || provider.oauth === undefined) { + return undefined; + } + const tokenProvider = this.oauth.resolveTokenProvider( + KIMI_CODE_PROVIDER_NAME, + provider.oauth, + ); + if (tokenProvider === undefined) { + return undefined; + } + const baseUrl = `${(provider.baseUrl ?? kimiCodeBaseUrl()).replace(/\/+$/, '')}/fetch`; + return new MoonshotFetchURLProvider({ + baseUrl, + tokenProvider, + defaultHeaders: { ...this.bootstrap.args.requestHeaders }, + customHeaders: provider.customHeaders, + localFallback: this.localFetcher, + telemetry: this.telemetry, + }); + } +} + +function nonEmptyString(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; +} + +registerScopedService( + LifecycleScope.App, + IWebFetchService, + WebFetchService, + ScopeActivation.OnScopeCreated, + 'web', +); diff --git a/packages/agent-core-v2/src/app/workspace/errors.ts b/packages/agent-core-v2/src/app/workspace/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..9c77527911e695b3398c55c9a6d355b2dd4d4058 --- /dev/null +++ b/packages/agent-core-v2/src/app/workspace/errors.ts @@ -0,0 +1,9 @@ +import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; + +export const WorkspaceErrors = { + codes: { + WORKSPACE_NOT_FOUND: 'workspace.not_found', + }, +} as const satisfies ErrorDomain; + +registerErrorDomain(WorkspaceErrors); diff --git a/packages/agent-core-v2/src/app/workspace/fileWorkspacePersistence.ts b/packages/agent-core-v2/src/app/workspace/fileWorkspacePersistence.ts new file mode 100644 index 0000000000000000000000000000000000000000..51c73714f7dcdfd9a16da8e147bc9b184993538a --- /dev/null +++ b/packages/agent-core-v2/src/app/workspace/fileWorkspacePersistence.ts @@ -0,0 +1,135 @@ +import { dirname, join, normalize } from 'pathe'; + +import { LifecycleScope } from '#/app/scopes'; + +import { Disposable } from '#/_base/di/lifecycle'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Emitter, type Event } from '#/_base/event'; +import { TimeoutTimer } from '#/_base/utils/timer'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { watch } from '#human/utils/watch'; + +import type { Workspace } from './workspace'; +import { + IWorkspacePersistence, + type PersistedWorkspaceEntry, + type PersistedWorkspaceFile, + type WorkspaceCatalog, +} from './workspacePersistence'; + +const WORKSPACE_CATALOG_VERSION = 1; +const WORKSPACE_CATALOG_SCOPE = ''; +const WORKSPACE_CATALOG_KEY = 'workspaces.json'; +const WATCH_DEBOUNCE_MS = 150; + +export class FileWorkspacePersistence extends Disposable implements IWorkspacePersistence { + declare readonly _serviceBrand: undefined; + + private readonly changeEmitter = this._register(new Emitter()); + readonly onDidChange: Event = this.changeEmitter.event; + private readonly watchDebounce = this._register(new TimeoutTimer()); + + constructor( + @IAtomicDocumentStore private readonly docs: IAtomicDocumentStore, + @IBootstrapService private readonly bootstrap: IBootstrapService, + ) { + super(); + const catalogFile = join(this.bootstrap.homeDir, WORKSPACE_CATALOG_KEY); + const handle = watch(dirname(catalogFile), { depth: 0 }); + this._register(handle); + this._register( + handle.onDidChange((change) => { + if (normalize(change.path) !== normalize(catalogFile)) return; + this.watchDebounce.cancelAndSet(() => { + this.changeEmitter.fire(); + }, WATCH_DEBOUNCE_MS); + }), + ); + } + + async load(): Promise { + const file = await this.docs.get( + WORKSPACE_CATALOG_SCOPE, + WORKSPACE_CATALOG_KEY, + ); + if (file === undefined) return undefined; + if ( + typeof file !== 'object' || + file === null || + typeof (file as { workspaces?: unknown }).workspaces !== 'object' || + (file as { workspaces?: unknown }).workspaces === null + ) { + return undefined; + } + const now = Date.now(); + const workspaces: Workspace[] = []; + for (const [id, raw] of Object.entries(file.workspaces)) { + const entry = sanitizeEntry(raw); + if (entry === null) continue; + workspaces.push({ + id, + root: entry.root, + name: entry.name, + createdAt: parseTime(entry.created_at, now), + lastOpenedAt: parseTime(entry.last_opened_at, now), + }); + } + const rawDeleted = (file as { deleted_workspace_ids?: unknown }).deleted_workspace_ids; + const deletedIds = Array.isArray(rawDeleted) + ? rawDeleted.filter((id): id is string => typeof id === 'string') + : []; + return { workspaces, deletedIds }; + } + + async save(catalog: WorkspaceCatalog): Promise { + const record: Record = {}; + for (const ws of catalog.workspaces) { + record[ws.id] = { + root: ws.root, + name: ws.name, + created_at: new Date(ws.createdAt).toISOString(), + last_opened_at: new Date(ws.lastOpenedAt).toISOString(), + }; + } + const file: PersistedWorkspaceFile = { + version: WORKSPACE_CATALOG_VERSION, + workspaces: record, + deleted_workspace_ids: [...catalog.deletedIds], + }; + await this.docs.set(WORKSPACE_CATALOG_SCOPE, WORKSPACE_CATALOG_KEY, file); + this.changeEmitter.fire(); + } +} + +function sanitizeEntry(value: unknown): PersistedWorkspaceEntry | null { + if (typeof value !== 'object' || value === null) return null; + const v = value as Partial; + if ( + typeof v.root !== 'string' || + typeof v.name !== 'string' || + typeof v.created_at !== 'string' || + typeof v.last_opened_at !== 'string' + ) { + return null; + } + return { + root: v.root, + name: v.name, + created_at: v.created_at, + last_opened_at: v.last_opened_at, + }; +} + +function parseTime(value: string, fallback: number): number { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? fallback : parsed; +} + +registerScopedService( + LifecycleScope.App, + IWorkspacePersistence, + FileWorkspacePersistence, + ScopeActivation.OnScopeCreated, + 'workspace', +); diff --git a/packages/agent-core-v2/src/app/workspace/workspace.ts b/packages/agent-core-v2/src/app/workspace/workspace.ts new file mode 100644 index 0000000000000000000000000000000000000000..a901392c57c5abd2b1006629b37d09c163a2e436 --- /dev/null +++ b/packages/agent-core-v2/src/app/workspace/workspace.ts @@ -0,0 +1,26 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface Workspace { + readonly id: string; + readonly root: string; + readonly name: string; + readonly createdAt: number; + readonly lastOpenedAt: number; +} + +export interface WorkspaceUpdate { + readonly name?: string; +} + +export interface IWorkspaceService { + readonly _serviceBrand: undefined; + + list(): Promise; + get(id: string): Promise; + createOrTouch(root: string, name?: string): Promise; + update(id: string, patch: WorkspaceUpdate): Promise; + delete(id: string): Promise; +} + +export const IWorkspaceService: ServiceIdentifier = + createDecorator('workspaceService'); diff --git a/packages/agent-core-v2/src/app/workspace/workspaceAlias.ts b/packages/agent-core-v2/src/app/workspace/workspaceAlias.ts new file mode 100644 index 0000000000000000000000000000000000000000..9545bb7c4e278a8f3b96f0d83fa01d8495b5df92 --- /dev/null +++ b/packages/agent-core-v2/src/app/workspace/workspaceAlias.ts @@ -0,0 +1,105 @@ +import { isAbsolute } from 'pathe'; + +import { encodeWorkDirKey, workspaceRootKey } from '#/_base/utils/workdir-slug'; +import type { IFileSystemStorageService } from '#/persistence/interface/storage'; + +import type { Workspace } from './workspace'; + +export const SESSION_INDEX_SCOPE = ''; +export const SESSION_INDEX_KEY = 'session_index.jsonl'; + +const textDecoder = new TextDecoder(); + +export interface SessionIndexLine { + readonly sessionId: string; + readonly sessionDir: string; + readonly workDir: string; +} + +export function collectAliasIds( + workspaces: readonly Workspace[], + sessionIndexEntries: readonly SessionIndexLine[], + root: string, +): string[] { + const rootKey = workspaceRootKey(root); + const ids: string[] = []; + const seen = new Set(); + const add = (alias: string): void => { + if (seen.has(alias)) return; + seen.add(alias); + ids.push(alias); + }; + for (const ws of workspaces) { + if (workspaceRootKey(ws.root) === rootKey) add(ws.id); + } + for (const line of sessionIndexEntries) { + if (workspaceRootKey(line.workDir) === rootKey) add(encodeWorkDirKey(line.workDir)); + } + return ids; +} + +export function dedupeByRoot(byId: ReadonlyMap): Workspace[] { + const byRoot = new Map(); + for (const ws of byId.values()) { + const rootKey = workspaceRootKey(ws.root); + const existing = byRoot.get(rootKey); + if (existing === undefined) { + byRoot.set(rootKey, ws); + continue; + } + const canonicalId = encodeWorkDirKey(ws.root); + if (existing.id !== canonicalId && ws.id === canonicalId) { + byRoot.set(rootKey, ws); + } + } + return [...byRoot.values()]; +} + +export async function readSessionIndexEntries( + storage: IFileSystemStorageService, +): Promise { + const bytes = await storage.read(SESSION_INDEX_SCOPE, SESSION_INDEX_KEY); + if (bytes === undefined) return []; + const entries: SessionIndexLine[] = []; + for (const line of textDecoder.decode(bytes).split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed === '') continue; + const entry = parseSessionIndexLine(trimmed); + if (entry === undefined) continue; + entries.push(entry); + } + return entries; +} + +export async function readSessionIndexWorkDirs( + storage: IFileSystemStorageService, +): Promise { + const workDirs: string[] = []; + for (const entry of await readSessionIndexEntries(storage)) { + if (!isAbsolute(entry.workDir)) continue; + workDirs.push(entry.workDir); + } + return workDirs; +} + +export function parseSessionIndexLine(line: string): SessionIndexLine | undefined { + try { + const parsed = JSON.parse(line) as unknown; + if (typeof parsed !== 'object' || parsed === null) return undefined; + const entry = parsed as Partial; + if ( + typeof entry.sessionId !== 'string' || + typeof entry.sessionDir !== 'string' || + typeof entry.workDir !== 'string' + ) { + return undefined; + } + return { + sessionId: entry.sessionId, + sessionDir: entry.sessionDir, + workDir: entry.workDir, + }; + } catch { + return undefined; + } +} diff --git a/packages/agent-core-v2/src/app/workspace/workspaceEvents.ts b/packages/agent-core-v2/src/app/workspace/workspaceEvents.ts new file mode 100644 index 0000000000000000000000000000000000000000..e726f5fd4204d82d1e5e7941f588f07ccc6b4d30 --- /dev/null +++ b/packages/agent-core-v2/src/app/workspace/workspaceEvents.ts @@ -0,0 +1,38 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { Event2 } from '#/app/event/event2'; + +import type { Workspace } from './workspace'; + +export interface WorkspaceCreatedPayload { + readonly workspace: Workspace; +} + +export class WorkspaceCreated extends Event2<{ readonly payload: WorkspaceCreatedPayload }> { + static override readonly type = 'event.workspace.created'; +} +export interface WorkspaceCreated { + readonly payload: WorkspaceCreatedPayload; +} + +export interface WorkspaceUpdatedPayload { + readonly workspace: Workspace; +} + +export class WorkspaceUpdated extends Event2<{ readonly payload: WorkspaceUpdatedPayload }> { + static override readonly type = 'event.workspace.updated'; +} +export interface WorkspaceUpdated { + readonly payload: WorkspaceUpdatedPayload; +} + +export interface WorkspaceDeletedPayload { + readonly workspaceId: string; + readonly root: string; +} + +export class WorkspaceDeleted extends Event2<{ readonly payload: WorkspaceDeletedPayload }> { + static override readonly type = 'event.workspace.deleted'; +} +export interface WorkspaceDeleted { + readonly payload: WorkspaceDeletedPayload; +} diff --git a/packages/agent-core-v2/src/app/workspace/workspacePersistence.ts b/packages/agent-core-v2/src/app/workspace/workspacePersistence.ts new file mode 100644 index 0000000000000000000000000000000000000000..f3b941e6231d8f24fec474e2fbc42447c77f782e --- /dev/null +++ b/packages/agent-core-v2/src/app/workspace/workspacePersistence.ts @@ -0,0 +1,34 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { Event } from '#/_base/event'; + +import type { Workspace } from './workspace'; + +export interface PersistedWorkspaceEntry { + readonly root: string; + readonly name: string; + readonly created_at: string; + readonly last_opened_at: string; +} + +export interface PersistedWorkspaceFile { + readonly version: number; + readonly workspaces: Record; + readonly deleted_workspace_ids: string[]; +} + +export interface WorkspaceCatalog { + readonly workspaces: readonly Workspace[]; + readonly deletedIds: readonly string[]; +} + +export interface IWorkspacePersistence { + readonly _serviceBrand: undefined; + + readonly onDidChange: Event; + + load(): Promise; + save(catalog: WorkspaceCatalog): Promise; +} + +export const IWorkspacePersistence: ServiceIdentifier = + createDecorator('workspacePersistence'); diff --git a/packages/agent-core-v2/src/app/workspace/workspaceProtocol.ts b/packages/agent-core-v2/src/app/workspace/workspaceProtocol.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb14d50827152f0f04a55586c8327f8417f2bcbd --- /dev/null +++ b/packages/agent-core-v2/src/app/workspace/workspaceProtocol.ts @@ -0,0 +1,24 @@ +export interface Workspace { + id: string; + root: string; + name: string; + created_at: string; + last_opened_at: string; + session_count: number; +} + +export interface WorkspaceCreatedEvent { + readonly type: 'event.workspace.created'; + readonly workspace: Workspace; +} + +export interface WorkspaceUpdatedEvent { + readonly type: 'event.workspace.updated'; + readonly workspace: Workspace; +} + +export interface WorkspaceDeletedEvent { + readonly type: 'event.workspace.deleted'; + readonly workspace_id: string; + readonly root: string; +} diff --git a/packages/agent-core-v2/src/app/workspace/workspaceService.ts b/packages/agent-core-v2/src/app/workspace/workspaceService.ts new file mode 100644 index 0000000000000000000000000000000000000000..dfd4daa3579933e53410ef669e884af6f0075c00 --- /dev/null +++ b/packages/agent-core-v2/src/app/workspace/workspaceService.ts @@ -0,0 +1,242 @@ +import { basename, isAbsolute } from 'pathe'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { encodeWorkDirKey, workspaceRootKey } from '#/_base/utils/workdir-slug'; +import { IEventService } from '#/app/event/event'; +import { ErrorCodes, Error2, unwrapErrorCause } from '#/errors'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { IFileSystemStorageService } from '#/persistence/interface/storage'; + +import { IWorkspaceService, type Workspace, type WorkspaceUpdate } from './workspace'; +import { + WorkspaceCreated, + WorkspaceDeleted, + WorkspaceUpdated, +} from './workspaceEvents'; +import { + collectAliasIds, + dedupeByRoot, + readSessionIndexEntries, + readSessionIndexWorkDirs, +} from './workspaceAlias'; +import { IWorkspacePersistence, type WorkspaceCatalog } from './workspacePersistence'; + +export class WorkspaceService implements IWorkspaceService { + declare readonly _serviceBrand: undefined; + + private merged = false; + private opQueue: Promise = Promise.resolve(); + + constructor( + @IWorkspacePersistence private readonly store: IWorkspacePersistence, + @IFileSystemStorageService private readonly storage: IFileSystemStorageService, + @IHostFileSystem private readonly hostFs: IHostFileSystem, + @IEventService private readonly event: IEventService, + ) {} + + list(): Promise { + return this.runExclusive(async () => { + await this.ensureMerged(); + const catalog = await this.loadCatalog(); + const byId = new Map(catalog.workspaces.map((ws) => [ws.id, ws])); + return dedupeByRoot(byId); + }); + } + + get(id: string): Promise { + return this.runExclusive(async () => { + await this.ensureMerged(); + const catalog = await this.loadCatalog(); + return catalog.workspaces.find((ws) => ws.id === id); + }); + } + + createOrTouch(root: string, name?: string): Promise { + return this.runExclusive(async () => { + let stat; + try { + stat = await this.hostFs.stat(root); + } catch (error) { + const code = (unwrapErrorCause(error) as NodeJS.ErrnoException | undefined)?.code; + if (code === 'ENOENT' || code === 'ENOTDIR') { + throw new Error2(ErrorCodes.FS_PATH_NOT_FOUND, `workspace root ${root} does not exist`); + } + throw error; + } + if (!stat.isDirectory) { + try { + stat = await this.hostFs.stat(await this.hostFs.realpath(root)); + } catch { + } + } + if (!stat.isDirectory) { + throw new Error2(ErrorCodes.FS_PATH_NOT_FOUND, `workspace root ${root} is not a directory`); + } + await this.ensureMerged(); + const catalog = await this.loadCatalog(); + const byId = new Map(catalog.workspaces.map((ws) => [ws.id, ws])); + const deletedIds = new Set(catalog.deletedIds); + const id = encodeWorkDirKey(root); + let existing = byId.get(id); + if (existing === undefined) { + const rootKey = workspaceRootKey(root); + for (const entry of byId.values()) { + if (workspaceRootKey(entry.root) === rootKey) { + existing = entry; + break; + } + } + } + const now = Date.now(); + const ws: Workspace = + existing !== undefined + ? { ...existing, lastOpenedAt: now } + : { + id, + root, + name: name ?? basename(root), + createdAt: now, + lastOpenedAt: now, + }; + byId.set(ws.id, ws); + deletedIds.delete(ws.id); + await this.store.save({ workspaces: [...byId.values()], deletedIds: [...deletedIds] }); + this.event.publish( + existing === undefined + ? new WorkspaceCreated({ payload: { workspace: ws } }) + : new WorkspaceUpdated({ payload: { workspace: ws } }), + ); + return ws; + }); + } + + update(id: string, patch: WorkspaceUpdate): Promise { + return this.runExclusive(async () => { + await this.ensureMerged(); + const catalog = await this.loadCatalog(); + const existing = catalog.workspaces.find((ws) => ws.id === id); + if (existing === undefined) return undefined; + const updated: Workspace = { + ...existing, + ...(patch.name !== undefined ? { name: patch.name } : {}), + }; + await this.store.save({ + workspaces: catalog.workspaces.map((ws) => (ws.id === id ? updated : ws)), + deletedIds: catalog.deletedIds, + }); + this.event.publish(new WorkspaceUpdated({ payload: { workspace: updated } })); + return updated; + }); + } + + delete(id: string): Promise { + return this.runExclusive(async () => { + await this.ensureMerged(); + const catalog = await this.loadCatalog(); + let root = catalog.workspaces.find((ws) => ws.id === id)?.root; + if (root === undefined) { + root = (await readSessionIndexEntries(this.storage)).find( + (line) => encodeWorkDirKey(line.workDir) === id, + )?.workDir; + } + if (root === undefined) { + await this.store.save({ + workspaces: catalog.workspaces.filter((ws) => ws.id !== id), + deletedIds: [...new Set([...catalog.deletedIds, id])], + }); + return; + } + const rootKey = workspaceRootKey(root); + const aliasIds = collectAliasIds( + catalog.workspaces, + await readSessionIndexEntries(this.storage), + root, + ); + await this.store.save({ + workspaces: catalog.workspaces.filter((ws) => workspaceRootKey(ws.root) !== rootKey), + deletedIds: [...new Set([...catalog.deletedIds, ...aliasIds])], + }); + this.event.publish(new WorkspaceDeleted({ payload: { workspaceId: id, root } })); + }); + } + + private async ensureMerged(): Promise { + if (this.merged) return; + const loaded = await this.store.load(); + if (loaded === undefined) { + const rebuilt = await this.rebuildFromSessionIndex(); + await this.store.save({ workspaces: [...rebuilt.values()], deletedIds: [] }); + this.merged = true; + return; + } + const byId = new Map(loaded.workspaces.map((ws) => [ws.id, ws])); + const deletedIds = new Set(loaded.deletedIds); + if (await this.mergeFromSessionIndex(byId, deletedIds)) { + await this.store.save({ workspaces: [...byId.values()], deletedIds: [...deletedIds] }); + } + this.merged = true; + } + + private async loadCatalog(): Promise { + return (await this.store.load()) ?? { workspaces: [], deletedIds: [] }; + } + + private async mergeFromSessionIndex( + byId: Map, + deletedIds: ReadonlySet, + ): Promise { + let changed = false; + const now = Date.now(); + for (const workDir of await readSessionIndexWorkDirs(this.storage)) { + const id = encodeWorkDirKey(workDir); + if (byId.has(id) || deletedIds.has(id)) continue; + byId.set(id, { + id, + root: workDir, + name: basename(workDir), + createdAt: now, + lastOpenedAt: now, + }); + changed = true; + } + return changed; + } + + private async rebuildFromSessionIndex(): Promise> { + const result = new Map(); + const now = Date.now(); + const seenRootKeys = new Set(); + for (const entry of await readSessionIndexEntries(this.storage)) { + if (!isAbsolute(entry.workDir)) continue; + const rootKey = workspaceRootKey(entry.workDir); + if (seenRootKeys.has(rootKey)) continue; + seenRootKeys.add(rootKey); + const id = encodeWorkDirKey(entry.workDir); + result.set(id, { + id, + root: entry.workDir, + name: basename(entry.workDir), + createdAt: now, + lastOpenedAt: now, + }); + } + return result; + } + + private runExclusive(op: () => Promise): Promise { + const next = this.opQueue.then(op, op); + this.opQueue = next.then( + () => {}, + () => {}, + ); + return next; + } +} + +registerScopedService( + LifecycleScope.App, + IWorkspaceService, + WorkspaceService, + ScopeActivation.OnScopeCreated, + 'workspace', +); diff --git a/packages/agent-core-v2/src/app/workspaceSessions/workspaceSessions.ts b/packages/agent-core-v2/src/app/workspaceSessions/workspaceSessions.ts new file mode 100644 index 0000000000000000000000000000000000000000..b7c195d96b8a349c5f78d3181a3e35514d361c85 --- /dev/null +++ b/packages/agent-core-v2/src/app/workspaceSessions/workspaceSessions.ts @@ -0,0 +1,17 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +import type { SessionSummary } from '#/app/sessionIndex/sessionIndex'; + +export type { SessionSummary }; + +export const RECENT_SESSIONS_LIMIT = 20; + +export interface IWorkspaceSessions { + readonly _serviceBrand: undefined; + + listRecent(workspaceId: string): Promise; + count(workspaceId: string): Promise; +} + +export const IWorkspaceSessions: ServiceIdentifier = + createDecorator('workspaceSessions'); diff --git a/packages/agent-core-v2/src/app/workspaceSessions/workspaceSessionsService.ts b/packages/agent-core-v2/src/app/workspaceSessions/workspaceSessionsService.ts new file mode 100644 index 0000000000000000000000000000000000000000..5531f8d7d4d95e22fd10930e6eb844fc3d8efcaf --- /dev/null +++ b/packages/agent-core-v2/src/app/workspaceSessions/workspaceSessionsService.ts @@ -0,0 +1,35 @@ +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; +import { IWorkspaceAliases } from '#/app/workspaceAliases/workspaceAliases'; + +import { IWorkspaceSessions, RECENT_SESSIONS_LIMIT } from './workspaceSessions'; + +export class WorkspaceSessionsService implements IWorkspaceSessions { + declare readonly _serviceBrand: undefined; + + constructor( + @IWorkspaceAliases private readonly aliases: IWorkspaceAliases, + @ISessionIndex private readonly index: ISessionIndex, + ) {} + + async listRecent(workspaceId: string): Promise { + const workspaceIds = await this.aliases.resolveAliasIds(workspaceId); + const page = await this.index.listRecent({ workspaceIds, limit: RECENT_SESSIONS_LIMIT }); + return page.items; + } + + async count(workspaceId: string): Promise { + const workspaceIds = await this.aliases.resolveAliasIds(workspaceId); + return this.index.count({ workspaceIds, includeArchived: true }); + } +} + +registerScopedService( + LifecycleScope.App, + IWorkspaceSessions, + WorkspaceSessionsService, + ScopeActivation.OnScopeCreated, + 'workspaceSessions', +); diff --git a/packages/agent-core-v2/src/features/btw/btw.ts b/packages/agent-core-v2/src/features/btw/btw.ts new file mode 100644 index 0000000000000000000000000000000000000000..7fa91fc3f8902b735bbe5ec451e1bf4b7ded450a --- /dev/null +++ b/packages/agent-core-v2/src/features/btw/btw.ts @@ -0,0 +1,31 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export const BTW_READONLY_TOOLS = new Set(['Read', 'Grep', 'Glob']); + +export const TOOL_CALL_DISABLED_MESSAGE = + 'Only the read-only tools Read, Grep, and Glob are available for side questions. Other tool calls are disabled.'; + +export const SIDE_QUESTION_SYSTEM_REMINDER = ` +This is a side-channel conversation with the user. You should answer user questions directly. + +IMPORTANT: +- You are a separate, lightweight instance. +- The main agent continues independently; do not reference being interrupted. +- You may use the read-only tools Read, Grep, and Glob to inspect files when + the answer depends on current file contents. All other tools are disabled + and will be rejected, even though their definitions are visible in this + request (they exist only for technical reasons — prompt cache). +- Prefer answering from what you already know from the conversation and this + side-channel conversation; reach for the read-only tools only when needed. +- Follow-up turns may happen in this side-channel conversation. +- If you do not know the answer, say so directly. +`.trim(); + +export interface ISessionBtwService { + readonly _serviceBrand: undefined; + + start(): Promise; +} + +export const ISessionBtwService: ServiceIdentifier = + createDecorator('sessionBtwService'); diff --git a/packages/agent-core-v2/src/features/btw/btwFeature.ts b/packages/agent-core-v2/src/features/btw/btwFeature.ts new file mode 100644 index 0000000000000000000000000000000000000000..1127b786a5f2e4ac8e5b6db770963679d9b70f6e --- /dev/null +++ b/packages/agent-core-v2/src/features/btw/btwFeature.ts @@ -0,0 +1,17 @@ +import { LifecycleScope } from '#/app/scopes'; +import { Feature } from '#/features/feature'; +import { registerFeature } from '#/features/featureRegistry'; + +import { ISessionBtwService } from './btw'; +import { SessionBtwService } from './btwService'; + +export class BtwFeature extends Feature { + static override readonly name = 'btw'; + + constructor() { + super(); + this.contributeService(LifecycleScope.Session, ISessionBtwService, SessionBtwService); + } +} + +registerFeature(BtwFeature); diff --git a/packages/agent-core-v2/src/features/btw/btwService.ts b/packages/agent-core-v2/src/features/btw/btwService.ts new file mode 100644 index 0000000000000000000000000000000000000000..aca4b12825c139c8935b56accb0f7b1ccf46920c --- /dev/null +++ b/packages/agent-core-v2/src/features/btw/btwService.ts @@ -0,0 +1,46 @@ +import { IAgentReminderService } from '#/features/reminder/reminderService'; +import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; +import { denyToolExecution } from '#/agent/toolExecutor/beforeToolExecuteEvent'; +import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { ErrorCodes, Error2 } from '#/errors'; +import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; + +import { + BTW_READONLY_TOOLS, + ISessionBtwService, + SIDE_QUESTION_SYSTEM_REMINDER, + TOOL_CALL_DISABLED_MESSAGE, +} from './btw'; + +export class SessionBtwService implements ISessionBtwService { + declare readonly _serviceBrand: undefined; + + constructor( + @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, + ) {} + + async start(): Promise { + const main = this.agentLifecycle.handleOf(MAIN_AGENT_ID); + if (main === undefined) { + throw new Error2(ErrorCodes.AGENT_NOT_FOUND, 'Main agent was not found'); + } + const childContext = await this.agentLifecycle.fork(main.accessor.get(IAgentScopeContext).agentContext); + const child = this.agentLifecycle.handleOf(childContext.agentId)!; + child.accessor + .get(IAgentReminderService) + .notify(SIDE_QUESTION_SYSTEM_REMINDER, { variant: 'btw' }); + const reason = + child.accessor.get(IAgentToolApprovalService)?.formatDenyMessage( + TOOL_CALL_DISABLED_MESSAGE, + ) ?? TOOL_CALL_DISABLED_MESSAGE; + child.accessor + .get(IAgentToolExecutorService) + ?.onBeforeExecuteTool((event) => { + if (!BTW_READONLY_TOOLS.has(event.toolCall.name)) { + event.veto(denyToolExecution(reason)); + } + }); + return childContext.agentId; + } +} diff --git a/packages/agent-core-v2/src/features/cron/configSection.ts b/packages/agent-core-v2/src/features/cron/configSection.ts new file mode 100644 index 0000000000000000000000000000000000000000..8576a75d8d77dd15d3b751c3bd8bda92783e774f --- /dev/null +++ b/packages/agent-core-v2/src/features/cron/configSection.ts @@ -0,0 +1,53 @@ +import { type ConfigStripEnv, type EnvBindings, envBindings } from '#/app/config/config'; +import { registerConfigSection } from '#/app/config/configSectionContributions'; + +export const CRON_SECTION = 'cron'; + +export interface CronConfig { + readonly debug: boolean; + readonly noJitter: boolean; + readonly noStale: boolean; + readonly disabled: boolean; + readonly manualTick: boolean; + readonly clock?: string; + readonly pollIntervalMs?: number | null; +} + +export const DEFAULT_CRON_CONFIG: CronConfig = { + debug: false, + noJitter: false, + noStale: false, + disabled: false, + manualTick: false, +}; + +const cronConfigSchema = { parse: (value: unknown): CronConfig => value as CronConfig }; + +const on = (raw: string): boolean => raw === '1'; + +function parsePollIntervalMs(raw: string): number | null | undefined { + const value = raw.trim(); + if (value.length === 0) return undefined; + if (value === 'null') return null; + const parsed = Number(value); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) return undefined; + return parsed; +} + +export const cronEnvBindings: EnvBindings = envBindings(cronConfigSchema, { + debug: { env: 'KIMI_CRON_DEBUG', parse: on }, + noJitter: { env: 'KIMI_CRON_NO_JITTER', parse: on }, + noStale: { env: 'KIMI_CRON_NO_STALE', parse: on }, + disabled: { env: 'KIMI_DISABLE_CRON', parse: on }, + manualTick: { env: 'KIMI_CRON_MANUAL_TICK', parse: on }, + clock: 'KIMI_CRON_CLOCK', + pollIntervalMs: { env: 'KIMI_CRON_POLL_INTERVAL_MS', parse: parsePollIntervalMs }, +}); + +export const stripCronEnv: ConfigStripEnv = () => undefined; + +registerConfigSection(CRON_SECTION, cronConfigSchema, { + defaultValue: DEFAULT_CRON_CONFIG, + env: cronEnvBindings, + stripEnv: stripCronEnv, +}); diff --git a/packages/agent-core-v2/src/features/cron/cronFeature.ts b/packages/agent-core-v2/src/features/cron/cronFeature.ts new file mode 100644 index 0000000000000000000000000000000000000000..3db70a5acee0d295b24e9703e91b43072194f893 --- /dev/null +++ b/packages/agent-core-v2/src/features/cron/cronFeature.ts @@ -0,0 +1,23 @@ +import { Feature } from '#/features/feature'; +import { registerFeature } from '#/features/featureRegistry'; +import { AgentCronService, IAgentCronService } from '#/features/cron/cronService'; +import { ICronCreateTool } from '#/features/cron/tools/cron-create/cron-create'; +import { CronCreateTool } from '#/features/cron/tools/cron-create/cronCreateTool'; +import { ICronDeleteTool } from '#/features/cron/tools/cron-delete/cron-delete'; +import { CronDeleteTool } from '#/features/cron/tools/cron-delete/cronDeleteTool'; +import { ICronListTool } from '#/features/cron/tools/cron-list/cron-list'; +import { CronListTool } from '#/features/cron/tools/cron-list/cronListTool'; + +export class CronFeature extends Feature { + static override readonly name = 'cron'; + + constructor() { + super(); + this.contributeAgentService(IAgentCronService, AgentCronService); + this.contributeTool(ICronCreateTool, CronCreateTool, { name: 'CronCreate', domain: 'cron' }); + this.contributeTool(ICronListTool, CronListTool, { name: 'CronList', domain: 'cron' }); + this.contributeTool(ICronDeleteTool, CronDeleteTool, { name: 'CronDelete', domain: 'cron' }); + } +} + +registerFeature(CronFeature); diff --git a/packages/agent-core-v2/src/features/cron/cronOps.ts b/packages/agent-core-v2/src/features/cron/cronOps.ts new file mode 100644 index 0000000000000000000000000000000000000000..544aa817c753a562523a044ec1b4398562989468 --- /dev/null +++ b/packages/agent-core-v2/src/features/cron/cronOps.ts @@ -0,0 +1,71 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { z } from 'zod'; + +import type { CronJobOrigin } from '#/agent/contextMemory/types'; +import type { CronTask } from '#/features/cron/cronTask'; +import { Event2 } from '#/app/event/event2'; + +export type CronModelState = Map; + +const cronTaskSchema = z.object({ + id: z.string(), + cron: z.string(), + prompt: z.string(), + createdAt: z.number(), + recurring: z.boolean().optional(), + lastFiredAt: z.number().optional(), + tags: z.record(z.string(), z.string()).optional(), +}); + +const cronAddSchema = z.object({ task: cronTaskSchema }); +const cronDeleteSchema = z.object({ ids: z.array(z.string()) }); +const cronCursorSchema = z.object({ id: z.string(), lastFiredAt: z.number() }); + +export interface CronAddPayload { + readonly task: CronTask; +} + +export class CronAdd extends Event2 { + static override readonly type = 'cron.add'; + static override readonly durable = true; + static override readonly schema = cronAddSchema; +} +export interface CronAdd extends CronAddPayload {} + +export interface CronDeletePayload { + readonly ids: readonly string[]; +} + +export class CronDelete extends Event2 { + static override readonly type = 'cron.delete'; + static override readonly durable = true; + static override readonly schema = cronDeleteSchema; +} +export interface CronDelete extends CronDeletePayload {} + +export interface CronCursorPayload { + readonly id: string; + readonly lastFiredAt: number; +} + +export class CronCursor extends Event2 { + static override readonly type = 'cron.cursor'; + static override readonly durable = true; + static override readonly schema = cronCursorSchema; +} +export interface CronCursor extends CronCursorPayload {} + +export interface CronFiredPayload { + readonly origin: CronJobOrigin; + readonly prompt: string; +} + +export class CronFired extends Event2 { + static override readonly type = 'cron.fired'; + static override readonly observable = true; +} +export interface CronFired extends CronFiredPayload {} + +export interface CronFiredEvent extends CronFiredPayload { + readonly type: 'cron.fired'; +} diff --git a/packages/agent-core-v2/src/features/cron/cronService.ts b/packages/agent-core-v2/src/features/cron/cronService.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d8d0d7e883311ddf73dc4843ecbc22ab9e91d60 --- /dev/null +++ b/packages/agent-core-v2/src/features/cron/cronService.ts @@ -0,0 +1,554 @@ +import { ulid } from 'ulid'; +import { assign, fromCallback, sendTo, setup, type Snapshot } from 'xstate'; + +import { createDecorator, IInstantiationService } from '#/_base/di/instantiation'; +import { IntervalTimer } from '#/_base/utils/timer'; +import type { CronJobOrigin, CronMissedOrigin } from '#/agent/contextMemory/types'; +import { IAgentLoopService, type Turn } from '#/agent/loop/loop'; +import { + AgentActorService, + type AgentActorContext, + type AgentActorRestoreEvent, +} from '#/agent/actorService/agentActorService'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { registerEvent2Class } from '#/app/event/event2'; +import { IConfigService } from '#/app/config/config'; +import { type ClockSources, resolveClockSources, SYSTEM_CLOCKS } from '#/features/cron/internal/clock'; +import { type CronConfig, CRON_SECTION, DEFAULT_CRON_CONFIG } from '#/features/cron/configSection'; +import { computeNextCronRun, parseCronExpression, type ParsedCronExpression } from '#/features/cron/internal/cron-expr'; +import type { CronTask, CronTaskInit } from '#/features/cron/cronTask'; +import { renderCronFireXml } from '#/features/cron/internal/format'; +import { jitteredNextCronRunMs, oneShotJitteredNextCronRunMs } from '#/features/cron/internal/jitter'; +import type { CronDeletedEvent, CronScheduledEvent } from '#/app/telemetry/events'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { BugIndicatingError } from '#/errors'; +import type { ContentPart } from '#human/llm/message'; +import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; +import { IEventDispatcher } from '#/state/eventDispatcher'; + +import { CronAdd, CronCursor, CronDelete, CronFired, type CronModelState } from './cronOps'; + +registerEvent2Class(CronAdd); +registerEvent2Class(CronDelete); +registerEvent2Class(CronCursor); + +const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; +const DEFAULT_POLL_INTERVAL_MS = 1_000; +const MAX_COALESCE_ITERATIONS = 10_000; +const CRON_ID_REGEX: RegExp = /^(?:[0-9a-f]{8}|[0-9A-HJKMNP-TV-Z]{26})$/i; +const MAX_ID_ATTEMPTS = 8; + +export const CRON_SCHEDULED = 'cron_scheduled' as const; +export const CRON_FIRED = 'cron_fired' as const; +export const CRON_MISSED = 'cron_missed' as const; +export const CRON_DELETED = 'cron_deleted' as const; + +interface CronActorContext { + readonly tasks: CronModelState; + readonly runtime: AgentActorContext; +} + +interface CronCommitEvent { + readonly type: 'cron.commit'; + readonly tasks: CronModelState; +} + +interface CronTickEvent { + readonly type: 'cron.tick'; + readonly resolve?: () => void; + readonly reject?: (error: unknown) => void; +} + +type CronActorEvent = CronCommitEvent | AgentActorRestoreEvent | CronTickEvent; +type CronActorSnapshot = Snapshot & { readonly context: CronActorContext }; + +interface CronEffectState { + clocks: ClockSources; + readonly parsedCache: Map; + readonly lastSeenAt: Map; + readonly seededFromStore: Set; + readonly inFlight: Set; +} + +function configOf(runtime: AgentActorContext): IConfigService { + return runtime.get(IConfigService); +} + +function cronConfigOf(runtime: AgentActorContext): CronConfig { + return configOf(runtime).get(CRON_SECTION) ?? DEFAULT_CRON_CONFIG; +} + +function clocksOf(runtime: AgentActorContext): ClockSources { + const config = cronConfigOf(runtime); + return resolveClockSources(config.clock, config.debug) ?? SYSTEM_CLOCKS; +} + +function telemetryOf(runtime: AgentActorContext): ITelemetryService { + return runtime.get(ITelemetryService); +} + +function debugLog(runtime: AgentActorContext, message: string): void { + if (cronConfigOf(runtime).debug) process.stderr.write(`[cron/session] ${message}\n`); +} + +function isStaleAt( + runtime: AgentActorContext, + task: CronTask, + now: number, +): boolean { + if (cronConfigOf(runtime).noStale) return false; + if (task.recurring === false) return false; + const age = now - task.createdAt; + return Number.isFinite(age) && age >= STALE_THRESHOLD_MS; +} + +function computeJitteredNext( + runtime: AgentActorContext, + task: CronTask, + parsed: ParsedCronExpression, + baseMs: number, +): number | null { + const ideal = computeNextCronRun(parsed, baseMs); + if (ideal === null) return null; + const noJitter = cronConfigOf(runtime).noJitter; + if (task.recurring === false) { + return oneShotJitteredNextCronRunMs(task, ideal, undefined, noJitter); + } + return jitteredNextCronRunMs(task, parsed, ideal, undefined, noJitter); +} + +function parsedCron(state: CronEffectState, expression: string): ParsedCronExpression { + const cached = state.parsedCache.get(expression); + if (cached !== undefined) return cached; + const parsed = parseCronExpression(expression); + state.parsedCache.set(expression, parsed); + return parsed; +} + +function countCoalesced( + runtime: AgentActorContext, + task: CronTask, + parsed: ParsedCronExpression, + firstFireMs: number, + nowMs: number, +): { count: number; lastDueMs: number } { + let count = 1; + let cursor = firstFireMs; + let lastDueMs = firstFireMs; + const noJitter = cronConfigOf(runtime).noJitter; + while (count < MAX_COALESCE_ITERATIONS) { + const next = computeNextCronRun(parsed, cursor); + if (next === null || next > nowMs) break; + const jitteredNext = task.recurring === false + ? oneShotJitteredNextCronRunMs(task, next, undefined, noJitter) + : jitteredNextCronRunMs(task, parsed, next, undefined, noJitter); + if (jitteredNext > nowMs) break; + count += 1; + cursor = next; + lastDueMs = next; + } + return { count, lastDueMs }; +} + +function removeTasks( + runtime: AgentActorContext, + ids: readonly string[], +): readonly string[] { + const removed = ids.filter((id) => runtime.getState().has(id)); + if (removed.length > 0) void runtime.dispatch(new CronDelete({ ids: removed })); + return removed; +} + +function deliverFire( + runtime: AgentActorContext, + task: CronTask, + context: { readonly coalescedCount: number; readonly firedAt: number }, +): Promise { + const origin: CronJobOrigin = { + kind: 'cron_job', + jobId: task.id, + cron: task.cron, + recurring: task.recurring !== false, + coalescedCount: context.coalescedCount, + stale: isStaleAt(runtime, task, context.firedAt), + }; + const buffered = runtime.get(IAgentLoopService).snapshot().state === 'running'; + try { + runtime.get(IAgentLoopService).submit( + { + message: { role: 'user', content: [{ type: 'text', text: renderCronFireXml(origin, task.prompt) }] }, + meta: { origin }, + }, + { steerIfActive: true }, + ); + } catch (error) { + debugLog(runtime, `steer threw for task ${task.id}: ${error instanceof Error ? error.message : String(error)}`); + return Promise.resolve(false); + } + void runtime.dispatch(new CronFired({ origin, prompt: task.prompt })); + telemetryOf(runtime).track2(CRON_FIRED, { + recurring: task.recurring !== false, + coalesced_count: context.coalescedCount, + stale: origin.stale, + buffered, + }); + return Promise.resolve(true); +} + +async function processDue( + runtime: AgentActorContext, + state: CronEffectState, + task: CronTask, + now: number, +): Promise { + if (state.inFlight.has(task.id)) return; + let parsed: ParsedCronExpression; + try { + parsed = parsedCron(state, task.cron); + } catch (error) { + debugLog(runtime, `tick failed to parse cron for task ${task.id}: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if ( + !state.seededFromStore.has(task.id) && + task.lastFiredAt !== undefined && + Number.isFinite(task.lastFiredAt) && + task.lastFiredAt <= now && + !state.lastSeenAt.has(task.id) + ) { + state.lastSeenAt.set(task.id, task.lastFiredAt); + } + state.seededFromStore.add(task.id); + const seen = state.lastSeenAt.get(task.id); + const baseFromMs = seen !== undefined && seen > task.createdAt ? seen : task.createdAt; + const nextFireAt = computeJitteredNext(runtime, task, parsed, baseFromMs); + if (nextFireAt === null || now < nextFireAt) return; + const ideal = computeNextCronRun(parsed, baseFromMs); + let coalescedCount = 1; + let lastDueMs: number | null = null; + if (task.recurring !== false && ideal !== null) { + const result = countCoalesced(runtime, task, parsed, ideal, now); + coalescedCount = Math.max(1, result.count); + lastDueMs = result.lastDueMs; + } + state.inFlight.add(task.id); + const firedAt = state.clocks.wallNow(); + let delivered = false; + try { + delivered = await deliverFire(runtime, task, { coalescedCount, firedAt }); + } catch (error) { + debugLog(runtime, `deliverDue threw for task ${task.id}: ${error instanceof Error ? error.message : String(error)}`); + } finally { + state.inFlight.delete(task.id); + } + if (!delivered) return; + if (task.recurring === false || isStaleAt(runtime, task, firedAt)) { + const removed = removeTasks(runtime, [task.id]); + state.lastSeenAt.delete(task.id); + state.seededFromStore.delete(task.id); + if (task.recurring !== false && removed.length > 0) { + const properties: CronDeletedEvent = { task_id: task.id, agent_id: undefined }; + telemetryOf(runtime).track2(CRON_DELETED, properties); + } + return; + } + const advancedTo = lastDueMs ?? now; + state.lastSeenAt.set(task.id, advancedTo); + if (runtime.getState().has(task.id)) { + void runtime.dispatch(new CronCursor({ id: task.id, lastFiredAt: advancedTo })); + } +} + +async function tickCron( + runtime: AgentActorContext, + state: CronEffectState, +): Promise { + await configOf(runtime).ready; + if (cronConfigOf(runtime).disabled || runtime.getState().size === 0) return; + if (runtime.get(IAgentLoopService).snapshot().state === 'running') return; + const now = state.clocks.wallNow(); + await Promise.all([...runtime.getState().values()].map((task) => processDue(runtime, state, task, now))); +} + +const cronEffects = fromCallback(({ + input, + receive, + sendBack, +}: { + input: { + readonly runtime: AgentActorContext; + readonly restore: AgentActorRestoreEvent; + }; + receive: (listener: (event: CronTickEvent) => void) => void; + sendBack: (event: CronActorEvent) => void; +}) => { + if (input.runtime.agent.agentId !== MAIN_AGENT_ID) return; + const timer = new IntervalTimer({ unref: true }); + const state: CronEffectState = { + clocks: SYSTEM_CLOCKS, + parsedCache: new Map(), + lastSeenAt: new Map(), + seededFromStore: new Set(), + inFlight: new Set(), + }; + let disposed = false; + let signalHandler: NodeJS.SignalsListener | undefined; + receive((event) => { + void tickCron(input.runtime, state).then( + () => { event.resolve?.(); }, + (error: unknown) => { event.reject?.(error); }, + ); + }); + input.restore.waitUntil(configOf(input.runtime).ready.then(() => { + if (disposed) return; + const config = cronConfigOf(input.runtime); + state.clocks = resolveClockSources(config.clock, config.debug) ?? SYSTEM_CLOCKS; + const poll = config.manualTick ? null : config.pollIntervalMs; + const interval = poll === undefined ? DEFAULT_POLL_INTERVAL_MS : poll; + if (interval !== null && interval !== 0) { + timer.cancelAndSet(() => { sendBack({ type: 'cron.tick' }); }, interval); + } + if (process.platform !== 'win32' && config.manualTick) { + signalHandler = () => { sendBack({ type: 'cron.tick' }); }; + process.on('SIGUSR1', signalHandler); + } + })); + return () => { + disposed = true; + timer.dispose(); + if (signalHandler !== undefined) process.off('SIGUSR1', signalHandler); + state.inFlight.clear(); + state.lastSeenAt.clear(); + state.seededFromStore.clear(); + state.parsedCache.clear(); + }; +}); + +function nextFireFor( + runtime: AgentActorContext, + task: CronTask, +): number | null { + try { + const clocks = clocksOf(runtime); + const parsed = parseCronExpression(task.cron); + const persistedCursor = + task.lastFiredAt !== undefined && + Number.isFinite(task.lastFiredAt) && + task.lastFiredAt <= clocks.wallNow() + ? task.lastFiredAt + : undefined; + const baseFromMs = + persistedCursor !== undefined && persistedCursor > task.createdAt + ? persistedCursor + : task.createdAt; + return computeJitteredNext(runtime, task, parsed, baseFromMs); + } catch (error) { + debugLog(runtime, `nextFireFor skipping task ${task.id}: ${error instanceof Error ? error.message : String(error)}`); + return null; + } +} + +const cronActorLogic = setup({ + types: {} as { + context: CronActorContext; + input: AgentActorContext; + events: CronActorEvent; + }, + actors: { cronEffects }, +}).createMachine({ + context: ({ input }) => ({ tasks: new Map(), runtime: input }), + initial: 'beforeRestore', + states: { + beforeRestore: { + on: { + 'runtime.restore': 'active', + 'cron.tick': { + actions: ({ event }) => { event.reject?.(new Error('Cron runtime is not restored')); }, + }, + }, + }, + active: { + invoke: { + id: 'cronEffects', + src: 'cronEffects', + input: ({ context, event }) => ({ + runtime: context.runtime, + restore: event as AgentActorRestoreEvent, + }), + }, + on: { + 'cron.tick': { actions: sendTo('cronEffects', ({ event }) => event) }, + }, + }, + }, + on: { + 'cron.commit': { + actions: assign({ tasks: ({ event }) => event.tasks }), + }, + }, +}); + +export interface IAgentCronService { + readonly _serviceBrand: undefined; + now(): number; + isDisabled(): boolean; + addTask(init: CronTaskInit): CronTask; + removeTasks(ids: readonly string[]): readonly string[]; + getTask(id: string): CronTask | undefined; + list(): readonly CronTask[]; + isStale(task: CronTask): boolean; + getNextFireTime(): number | null; + getNextFireForTask(taskId: string): number | null; + computeDisplayNextFire( + task: CronTask, + parsed: ParsedCronExpression, + idealMs: number, + ): number | null; + handleMissed( + tasks: readonly CronTask[], + renderMissedNotification: (tasks: readonly CronTask[]) => readonly ContentPart[], + ): Turn | undefined; + emitScheduled(task: CronTask, agentId?: string): void; + emitDeleted(taskId: string, agentId?: string): void; + tick(): Promise; +} + +export const IAgentCronService = createDecorator('agentCronService'); + +export class AgentCronService extends AgentActorService implements IAgentCronService { + declare readonly _serviceBrand: undefined; + + private readonly actor: AgentActorContext; + + constructor( + @IEventDispatcher dispatcher: IEventDispatcher, + @IAgentScopeContext scopeContext: IAgentScopeContext, + @IInstantiationService instantiation: IInstantiationService, + ) { + super(dispatcher, scopeContext, instantiation); + this.actor = this.attachActor(cronActorLogic, { + id: 'cron', + durable: { + events: [CronAdd, CronDelete, CronCursor], + undoable: false, + transition: (state, event) => { + if (event instanceof CronAdd) { + state.set(event.task.id, event.task); + return; + } + if (event instanceof CronDelete) { + for (const id of event.ids) state.delete(id); + return; + } + if (event instanceof CronCursor) { + const task = state.get(event.id); + if (task !== undefined) state.set(event.id, { ...task, lastFiredAt: event.lastFiredAt }); + } + }, + read: (snapshot) => (snapshot as CronActorSnapshot).context.tasks, + commit: (actor, tasks) => { actor.send({ type: 'cron.commit', tasks }); }, + }, + }); + } + + now(): number { + return clocksOf(this.actor).wallNow(); + } + + isDisabled(): boolean { + return cronConfigOf(this.actor).disabled; + } + + addTask(init: CronTaskInit): CronTask { + const tasks = this.actor.getState(); + let id: string | undefined; + for (let attempt = 0; attempt < MAX_ID_ATTEMPTS; attempt += 1) { + const candidate = ulid(); + if (CRON_ID_REGEX.test(candidate) && !tasks.has(candidate)) { + id = candidate; + break; + } + } + if (id === undefined) { + throw new BugIndicatingError(`SessionCronService: failed to generate a unique ULID after ${MAX_ID_ATTEMPTS} attempts`); + } + const task: CronTask = { ...init, id, createdAt: this.now() }; + void this.actor.dispatch(new CronAdd({ task })); + return task; + } + + removeTasks(ids: readonly string[]): readonly string[] { + return removeTasks(this.actor, ids); + } + + getTask(id: string): CronTask | undefined { + return this.actor.getState().get(id); + } + + list(): readonly CronTask[] { + return [...this.actor.getState().values()]; + } + + isStale(task: CronTask): boolean { + return isStaleAt(this.actor, task, this.now()); + } + + getNextFireTime(): number | null { + let min: number | null = null; + for (const task of this.actor.getState().values()) { + const next = nextFireFor(this.actor, task); + if (next !== null && (min === null || next < min)) min = next; + } + return min; + } + + getNextFireForTask(taskId: string): number | null { + const task = this.actor.getState().get(taskId); + return task === undefined ? null : nextFireFor(this.actor, task); + } + + computeDisplayNextFire( + task: CronTask, + parsed: ParsedCronExpression, + idealMs: number, + ): number | null { + const noJitter = cronConfigOf(this.actor).noJitter; + if (task.recurring === false) { + return oneShotJitteredNextCronRunMs(task, idealMs, undefined, noJitter); + } + return jitteredNextCronRunMs(task, parsed, idealMs, undefined, noJitter); + } + + handleMissed( + tasks: readonly CronTask[], + renderMissedNotification: (tasks: readonly CronTask[]) => readonly ContentPart[], + ): Turn | undefined { + if (tasks.length === 0) return undefined; + const origin: CronMissedOrigin = { kind: 'cron_missed', count: tasks.length }; + this.actor.get(IAgentLoopService).submit( + { + message: { role: 'user', content: [...renderMissedNotification(tasks)] }, + meta: { origin }, + }, + { steerIfActive: true }, + ); + telemetryOf(this.actor).track2(CRON_MISSED, { count: tasks.length }); + return undefined; + } + + emitScheduled(task: CronTask, agentId?: string): void { + const properties: CronScheduledEvent = { recurring: task.recurring !== false, agent_id: agentId }; + telemetryOf(this.actor).track2(CRON_SCHEDULED, properties); + } + + emitDeleted(taskId: string, agentId?: string): void { + const properties: CronDeletedEvent = { task_id: taskId, agent_id: agentId }; + telemetryOf(this.actor).track2(CRON_DELETED, properties); + } + + tick(): Promise { + return new Promise((resolve, reject) => { + this.actor.send({ type: 'cron.tick', resolve, reject }); + }); + } +} diff --git a/packages/agent-core-v2/src/features/cron/cronTask.ts b/packages/agent-core-v2/src/features/cron/cronTask.ts new file mode 100644 index 0000000000000000000000000000000000000000..134fa66e82d3129704d0ac02c874cdaff74ec121 --- /dev/null +++ b/packages/agent-core-v2/src/features/cron/cronTask.ts @@ -0,0 +1,11 @@ +export interface CronTask { + readonly id: string; + readonly cron: string; + readonly prompt: string; + readonly createdAt: number; + readonly recurring?: boolean; + readonly lastFiredAt?: number; + readonly tags?: Readonly>; +} + +export type CronTaskInit = Omit; diff --git a/packages/agent-core-v2/src/features/cron/errors.ts b/packages/agent-core-v2/src/features/cron/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..fc8af76db51df6d1c27f337e05dc30a6194e6cc8 --- /dev/null +++ b/packages/agent-core-v2/src/features/cron/errors.ts @@ -0,0 +1,9 @@ +import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; + +export const CronErrors = { + codes: { + CRON_EXPRESSION_INVALID: 'cron.expression_invalid', + }, +} as const satisfies ErrorDomain; + +registerErrorDomain(CronErrors); diff --git a/packages/agent-core-v2/src/features/cron/internal/clock.ts b/packages/agent-core-v2/src/features/cron/internal/clock.ts new file mode 100644 index 0000000000000000000000000000000000000000..9765322f9f2b1dfafae24d280e4706eec84354eb --- /dev/null +++ b/packages/agent-core-v2/src/features/cron/internal/clock.ts @@ -0,0 +1,72 @@ +import { closeSync, openSync, readSync } from 'node:fs'; + +export interface ClockSources { + wallNow(): number; + + monoNowMs(): number; +} + +const systemMonoNowMs = (): number => Number(process.hrtime.bigint() / 1_000_000n); + +export const SYSTEM_CLOCKS: ClockSources = { + wallNow: () => Date.now(), + monoNowMs: systemMonoNowMs, +}; + +export function resolveClockSources(spec?: string, debug = false): ClockSources { + if (spec === undefined || spec === '' || spec === 'system') { + return SYSTEM_CLOCKS; + } + + if (spec.startsWith('file:')) { + const filePath = spec.slice('file:'.length); + if (filePath === '') { + debugInvalidSpec(spec, 'empty file path', debug); + return SYSTEM_CLOCKS; + } + return { + wallNow: () => readFileWall(filePath), + monoNowMs: systemMonoNowMs, + }; + } + + debugInvalidSpec(spec, 'unrecognised scheme', debug); + return SYSTEM_CLOCKS; +} + +const MAX_CLOCK_FILE_BYTES = 64; + +function readFileWall(filePath: string): number { + let bytesRead = 0; + const buf = Buffer.alloc(MAX_CLOCK_FILE_BYTES); + let fd: number; + try { + fd = openSync(filePath, 'r'); + } catch { + return Date.now(); + } + try { + bytesRead = readSync(fd, buf, 0, MAX_CLOCK_FILE_BYTES, 0); + } catch { + return Date.now(); + } finally { + try { + closeSync(fd); + } catch { + } + } + const raw = buf.subarray(0, bytesRead).toString('utf8'); + const firstLine = raw.split('\n', 1)[0]?.trim() ?? ''; + if (firstLine === '') return Date.now(); + const parsed = Number(firstLine); + if (!Number.isFinite(parsed)) return Date.now(); + return parsed; +} + +function debugInvalidSpec(spec: string, reason: string, debug: boolean): void { + if (debug) { + process.stderr.write( + `[cron/clock] invalid KIMI_CRON_CLOCK spec ${JSON.stringify(spec)}: ${reason} — falling back to system clock\n`, + ); + } +} diff --git a/packages/agent-core-v2/src/features/cron/internal/cron-expr.ts b/packages/agent-core-v2/src/features/cron/internal/cron-expr.ts new file mode 100644 index 0000000000000000000000000000000000000000..406bbe4e320ee21bb99d677b4f95b715b15545df --- /dev/null +++ b/packages/agent-core-v2/src/features/cron/internal/cron-expr.ts @@ -0,0 +1,387 @@ +import { Error2, ErrorCodes } from '#/errors'; + +export interface ParsedCronExpression { + readonly raw: string; + readonly minutes: ReadonlySet; + readonly hours: ReadonlySet; + readonly daysOfMonth: ReadonlySet; + readonly months: ReadonlySet; + readonly daysOfWeek: ReadonlySet; + readonly daysOfMonthWildcard: boolean; + readonly daysOfWeekWildcard: boolean; +} + +const MINUTE_RANGE = { min: 0, max: 59 } as const; +const HOUR_RANGE = { min: 0, max: 23 } as const; +const DOM_RANGE = { min: 1, max: 31 } as const; +const MONTH_RANGE = { min: 1, max: 12 } as const; +const DOW_RANGE = { min: 0, max: 7 } as const; + +const MS_PER_MINUTE = 60_000; + +export function parseCronExpression(expr: string): ParsedCronExpression { + if (typeof expr !== 'string') { + throw new Error2(ErrorCodes.CRON_EXPRESSION_INVALID, 'cron expression must be a string', { + details: { received: typeof expr }, + }); + } + const trimmed = expr.trim(); + if (trimmed === '') { + throw new Error2(ErrorCodes.CRON_EXPRESSION_INVALID, 'cron expression is empty'); + } + const fields = trimmed.split(/\s+/); + if (fields.length !== 5) { + throw new Error2( + ErrorCodes.CRON_EXPRESSION_INVALID, + `cron expression must have exactly 5 fields (minute hour day-of-month month day-of-week); got ${fields.length}`, + { details: { fieldCount: fields.length } }, + ); + } + const [minField, hourField, domField, monthField, dowField] = fields as [ + string, + string, + string, + string, + string, + ]; + + const minutes = parseField(minField, MINUTE_RANGE.min, MINUTE_RANGE.max, 'minute'); + const hours = parseField(hourField, HOUR_RANGE.min, HOUR_RANGE.max, 'hour'); + const daysOfMonth = parseField(domField, DOM_RANGE.min, DOM_RANGE.max, 'day-of-month'); + const months = parseField(monthField, MONTH_RANGE.min, MONTH_RANGE.max, 'month'); + const dowRaw = parseField(dowField, DOW_RANGE.min, DOW_RANGE.max, 'day-of-week'); + const daysOfWeek = new Set(); + for (const v of dowRaw) daysOfWeek.add(v === 7 ? 0 : v); + + return { + raw: trimmed, + minutes, + hours, + daysOfMonth, + months, + daysOfWeek, + daysOfMonthWildcard: isWildcard(domField), + daysOfWeekWildcard: isWildcard(dowField), + }; +} + +function isWildcard(field: string): boolean { + return field === '*'; +} + +function parseField(field: string, min: number, max: number, name: string): Set { + if (field === '') { + throw new Error2(ErrorCodes.CRON_EXPRESSION_INVALID, `cron ${name} field is empty`, { + details: { field: name }, + }); + } + const out = new Set(); + const terms = field.split(','); + for (const term of terms) { + if (term === '') { + throw new Error2( + ErrorCodes.CRON_EXPRESSION_INVALID, + `cron ${name} field has empty term in list`, + { details: { field: name } }, + ); + } + addTerm(out, term, min, max, name); + } + if (out.size === 0) { + throw new Error2(ErrorCodes.CRON_EXPRESSION_INVALID, `cron ${name} field matches no values`, { + details: { field: name }, + }); + } + return out; +} + +const DIGIT_ONLY = /^\d+$/; + +function parseCronInt(raw: string, name: string, role: string): number { + if (!DIGIT_ONLY.test(raw)) { + throw new Error2( + ErrorCodes.CRON_EXPRESSION_INVALID, + `cron ${name} ${role} must be a non-negative integer with digits only (got ${JSON.stringify(raw)})`, + { details: { field: name, role, value: raw } }, + ); + } + return Number.parseInt(raw, 10); +} + +function addTerm(out: Set, term: string, min: number, max: number, name: string): void { + let rangePart = term; + let step = 1; + const slash = term.indexOf('/'); + if (slash !== -1) { + rangePart = term.slice(0, slash); + const stepStr = term.slice(slash + 1); + if (stepStr === '') { + throw new Error2(ErrorCodes.CRON_EXPRESSION_INVALID, `cron ${name} step is empty in "${term}"`, { + details: { field: name, term }, + }); + } + const parsedStep = parseCronInt(stepStr, name, 'step'); + if (parsedStep <= 0) { + throw new Error2( + ErrorCodes.CRON_EXPRESSION_INVALID, + `cron ${name} step must be a positive integer (got "${stepStr}")`, + { details: { field: name, term, step: stepStr } }, + ); + } + step = parsedStep; + if (rangePart === '') { + throw new Error2( + ErrorCodes.CRON_EXPRESSION_INVALID, + `cron ${name} step needs a range or "*" before "/" in "${term}"`, + { details: { field: name, term } }, + ); + } + } + + let lo: number; + let hi: number; + if (rangePart === '*') { + lo = min; + hi = max; + } else { + const dash = rangePart.indexOf('-'); + if (dash === -1) { + const single = parseCronInt(rangePart, name, 'value'); + if (single < min || single > max) { + throw new Error2( + ErrorCodes.CRON_EXPRESSION_INVALID, + `cron ${name} value ${single} out of range ${min}..${max}`, + { details: { field: name, value: single, min, max } }, + ); + } + if (slash !== -1) { + lo = single; + hi = max; + } else { + out.add(single); + return; + } + } else { + const loStr = rangePart.slice(0, dash); + const hiStr = rangePart.slice(dash + 1); + lo = parseCronInt(loStr, name, 'range lower bound'); + hi = parseCronInt(hiStr, name, 'range upper bound'); + if (lo < min || hi > max || lo > hi) { + throw new Error2( + ErrorCodes.CRON_EXPRESSION_INVALID, + `cron ${name} range ${lo}-${hi} out of bounds (must be ${min}..${max}, ascending)`, + { details: { field: name, lo, hi, min, max } }, + ); + } + } + } + + for (let v = lo; v <= hi; v += step) { + out.add(v); + } +} + +export function computeNextCronRun(expr: ParsedCronExpression, fromMs: number): number | null { + return nextRunWithinMinutes(expr, fromMs, 5 * 366 * 24 * 60); +} + +export function hasFireWithinYears( + expr: ParsedCronExpression, + years: number, + fromMs: number, +): boolean { + const cap = Math.max(1, Math.floor(years * 366 * 24 * 60)); + return nextRunWithinMinutes(expr, fromMs, cap) !== null; +} + +function nextRunWithinMinutes( + expr: ParsedCronExpression, + fromMs: number, + capMinutes: number, +): number | null { + const start = new Date(fromMs); + start.setSeconds(0, 0); + const date = new Date(start.getTime() + MS_PER_MINUTE); + + const deadlineMs = fromMs + capMinutes * MS_PER_MINUTE; + + let iterations = 0; + const HARD_ITERATION_CAP = 10_000_000; + + while (date.getTime() <= deadlineMs && iterations++ < HARD_ITERATION_CAP) { + if (!expr.months.has(date.getMonth() + 1)) { + advanceMonth(date); + continue; + } + + if (!dayMatches(expr, date)) { + advanceDay(date); + continue; + } + + if (!expr.hours.has(date.getHours())) { + advanceHour(date); + continue; + } + + if (!expr.minutes.has(date.getMinutes())) { + advanceMinute(date); + continue; + } + + return date.getTime(); + } + + return null; +} + +function dayMatches(expr: ParsedCronExpression, date: Date): boolean { + const dom = date.getDate(); + const dow = date.getDay(); + const domOk = expr.daysOfMonth.has(dom); + const dowOk = expr.daysOfWeek.has(dow); + + if (expr.daysOfMonthWildcard && expr.daysOfWeekWildcard) return true; + if (expr.daysOfMonthWildcard) return dowOk; + if (expr.daysOfWeekWildcard) return domOk; + return domOk || dowOk; +} + +function advanceMonth(date: Date): void { + date.setDate(1); + date.setHours(0, 0, 0, 0); + date.setMonth(date.getMonth() + 1); +} + +function advanceDay(date: Date): void { + date.setHours(0, 0, 0, 0); + date.setDate(date.getDate() + 1); +} + +function advanceHour(date: Date): void { + date.setMinutes(0, 0, 0); + date.setHours(date.getHours() + 1); +} + +function advanceMinute(date: Date): void { + date.setSeconds(0, 0); + date.setMinutes(date.getMinutes() + 1); +} + +const MONTH_NAMES = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +] as const; + +const DAY_NAMES = [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', +] as const; + +export function cronToHuman(expr: ParsedCronExpression): string { + const allMin = isFullRange(expr.minutes, 0, 59); + const allHour = isFullRange(expr.hours, 0, 23); + const allDom = expr.daysOfMonthWildcard; + const allMonth = isFullRange(expr.months, 1, 12); + const allDow = expr.daysOfWeekWildcard; + + if (allHour && allDom && allMonth && allDow) { + const step = detectStep(expr.minutes, 0, 59); + if (step !== null && step > 1) return `every ${step} minutes`; + if (allMin) return 'every minute'; + if (expr.minutes.size === 1) { + const m = [...expr.minutes][0]!; + return `at minute ${m} of every hour`; + } + } + + if (expr.minutes.size === 1 && allDom && allMonth && allDow) { + const m = [...expr.minutes][0]!; + const step = detectStep(expr.hours, 0, 23); + if (step !== null && step > 1) { + return `every ${step} hours at minute ${pad(m)}`; + } + } + + if ( + expr.minutes.size === 1 && + expr.hours.size === 1 && + allDom && + allMonth + ) { + const h = [...expr.hours][0]!; + const m = [...expr.minutes][0]!; + if (allDow) return `at ${pad(h)}:${pad(m)} every day`; + const dowStr = formatDows(expr.daysOfWeek); + if (dowStr !== null) return `at ${pad(h)}:${pad(m)} on ${dowStr}`; + } + + if ( + expr.minutes.size === 1 && + expr.hours.size === 1 && + expr.daysOfMonth.size === 1 && + !expr.daysOfMonthWildcard && + expr.months.size === 1 && + allDow + ) { + const h = [...expr.hours][0]!; + const m = [...expr.minutes][0]!; + const d = [...expr.daysOfMonth][0]!; + const mo = [...expr.months][0]!; + return `at ${pad(h)}:${pad(m)} on day ${d} of ${MONTH_NAMES[mo - 1]}`; + } + + return expr.raw; +} + +function isFullRange(set: ReadonlySet, min: number, max: number): boolean { + if (set.size !== max - min + 1) return false; + for (let v = min; v <= max; v++) if (!set.has(v)) return false; + return true; +} + +function detectStep(set: ReadonlySet, min: number, max: number): number | null { + const values = [...set].toSorted((a, b) => a - b); + if (values.length < 2) return null; + if (values[0] !== min) return null; + const step = values[1]! - values[0]!; + if (step <= 0) return null; + let expected = min; + for (const v of values) { + if (v !== expected) return null; + expected += step; + } + if (expected - step > max) return null; + return step; +} + +function formatDows(set: ReadonlySet): string | null { + const values = [...set].toSorted((a, b) => a - b); + if (values.length === 0) return null; + if (values.length === 5 && values.every((v, i) => v === i + 1)) { + return 'weekdays'; + } + if (values.length === 2 && values[0] === 0 && values[1] === 6) { + return 'weekends'; + } + return values.map((v) => DAY_NAMES[v]!).join(', '); +} + +function pad(n: number): string { + return n < 10 ? `0${n}` : String(n); +} diff --git a/packages/agent-core-v2/src/features/cron/internal/format.ts b/packages/agent-core-v2/src/features/cron/internal/format.ts new file mode 100644 index 0000000000000000000000000000000000000000..19f854262201c497f529736e5911de34e4fcd687 --- /dev/null +++ b/packages/agent-core-v2/src/features/cron/internal/format.ts @@ -0,0 +1,41 @@ +import type { CronJobOrigin } from '#/agent/contextMemory/types'; + +export function formatLocalIsoWithOffset(ms: number): string { + const date = new Date(ms); + const offsetMin = -date.getTimezoneOffset(); + const sign = offsetMin >= 0 ? '+' : '-'; + const absOffset = Math.abs(offsetMin); + const offset = `${sign}${pad(Math.floor(absOffset / 60))}:${pad(absOffset % 60)}`; + + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad( + date.getHours(), + )}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${String(date.getMilliseconds()).padStart( + 3, + '0', + )}${offset}`; +} + +export function renderCronFireXml(origin: CronJobOrigin, prompt: string): string { + const jobId = stringAttr(origin.jobId, 'unknown'); + const cron = stringAttr(origin.cron, 'unknown'); + const recurring = origin.recurring ? 'true' : 'false'; + const coalescedCount = String(origin.coalescedCount); + const stale = origin.stale ? 'true' : 'false'; + + return [ + ``, + '', + prompt, + '', + '', + ].join('\n'); +} + +function pad(n: number): string { + return String(n).padStart(2, '0'); +} + +function stringAttr(value: unknown, fallback: string): string { + if (typeof value !== 'string' || value.length === 0) return fallback; + return value.replaceAll('&', '&').replaceAll('"', '"'); +} diff --git a/packages/agent-core-v2/src/features/cron/internal/jitter.ts b/packages/agent-core-v2/src/features/cron/internal/jitter.ts new file mode 100644 index 0000000000000000000000000000000000000000..ef565e6d0e59db88538eaa37192a2e9181db1264 --- /dev/null +++ b/packages/agent-core-v2/src/features/cron/internal/jitter.ts @@ -0,0 +1,85 @@ +import type { ParsedCronExpression } from './cron-expr'; +import { computeNextCronRun } from './cron-expr'; + +export interface JitterConfig { + readonly recurringMaxFractionOfPeriod: number; + readonly recurringMaxMs: number; + readonly oneShotMaxMs: number; +} + +export const DEFAULT_CRON_JITTER_CONFIG: JitterConfig = { + recurringMaxFractionOfPeriod: 0.1, + recurringMaxMs: 15 * 60_000, + oneShotMaxMs: 90_000, +}; + +const MS_PER_DAY = 24 * 60 * 60_000; +const MS_PER_MINUTE = 60_000; + +function fractionFromId(id: string): number { + if (/^[0-9a-f]{8}$/i.test(id)) { + const n = Number.parseInt(id, 16); + if (Number.isFinite(n)) { + return n / 0x1_0000_0000; + } + } + let hash = 5381; + for (let i = 0; i < id.length; i++) { + hash = ((hash << 5) + hash + id.charCodeAt(i)) | 0; + } + const unsigned = hash >>> 0; + return unsigned / 0x1_0000_0000; +} + +function jitterDisabled(noJitter: boolean | undefined): boolean { + return noJitter === true; +} + +export function jitteredNextCronRunMs( + task: { id: string; cron: string; recurring?: boolean }, + parsed: ParsedCronExpression, + idealMs: number, + config: JitterConfig = DEFAULT_CRON_JITTER_CONFIG, + noJitter?: boolean, +): number { + if (jitterDisabled(noJitter)) { + return idealMs; + } + const nextNext = computeNextCronRun(parsed, idealMs); + const period = + nextNext !== null && nextNext > idealMs ? nextNext - idealMs : MS_PER_DAY; + const periodCap = period * config.recurringMaxFractionOfPeriod; + const cap = Math.min(periodCap, config.recurringMaxMs); + if (!(cap > 0)) { + return idealMs; + } + const offset = cap * fractionFromId(task.id); + return idealMs + offset; +} + +export function oneShotJitteredNextCronRunMs( + task: { id: string; createdAt?: number | undefined }, + idealMs: number, + config: JitterConfig = DEFAULT_CRON_JITTER_CONFIG, + noJitter?: boolean, +): number { + if (jitterDisabled(noJitter)) { + return idealMs; + } + if (idealMs % MS_PER_MINUTE !== 0) { + return idealMs; + } + const minuteOfHour = new Date(idealMs).getMinutes(); + if (minuteOfHour !== 0 && minuteOfHour !== 30) { + return idealMs; + } + if (!(config.oneShotMaxMs > 0)) { + return idealMs; + } + const offset = -config.oneShotMaxMs * fractionFromId(task.id); + const shifted = idealMs + offset; + if (task.createdAt !== undefined && shifted < task.createdAt) { + return idealMs; + } + return shifted; +} diff --git a/packages/agent-core-v2/src/features/cron/tools/cron-create/cron-create.md b/packages/agent-core-v2/src/features/cron/tools/cron-create/cron-create.md new file mode 100644 index 0000000000000000000000000000000000000000..8448968186e0ac5de35bd07ec4368ff8681dffc0 --- /dev/null +++ b/packages/agent-core-v2/src/features/cron/tools/cron-create/cron-create.md @@ -0,0 +1,90 @@ +Schedule a prompt to be enqueued at a future time. Use for both recurring schedules and one-shot reminders. + +Uses standard 5-field cron in the user's local timezone: minute hour day-of-month month day-of-week. `0 9 * * *` means 9am local — no timezone conversion needed. + +## One-shot tasks (recurring: false) + +For "remind me at X" or "at