Repository navigation
feat(agent-core-v2): persist the last turn outcome into session metadata for cold listings #2666
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 5 commits
Commits
Show all changes
30 commits
Select commit
Hold shift + click to select a range
a9c7f74
feat(agent-core-v2): persist the last turn outcome into session metad…
wbxl2000 a90c83c
fix(klient): carry lastTurnReason/lastTurnOutcome in the validated co…
wbxl2000 b7e2c36
fix(agent-core-v2): persist user-cancelled outcomes, never teardown a…
wbxl2000 5e2561b
fix(kap-server): only fall back to the persisted outcome for cold ses…
wbxl2000 a6efba8
docs(agent-core-v2): keep the outcome-recorder header at role level
wbxl2000 c442515
fix(agent-core-v2): settle turn outcomes on turn start and drain meta…
wbxl2000 241c41f
fix(agent-core-v2): track the metadata dispose flag locally
wbxl2000 532037c
fix(kap-server): drain session metadata writes before the mirror and …
wbxl2000 21559fa
fix(agent-core-v2): reattach the recorder when the main agent is recr…
wbxl2000 5ef0c87
test(agent-core-v2): resolve the recorder through the scoped DI harness
wbxl2000 c62d190
fix(agent-core-v2): unbreak CI — iterable Promise.all and the debug c…
wbxl2000 345c761
fix(kap-server): surface persisted failures on the v2 session status
wbxl2000 e83a171
refactor(agent-core-v2): name the persisted field lastTurnReason
wbxl2000 c9ba5a1
fix(agent-core-v2): drain pending metadata writes before session tear…
wbxl2000 69b35ae
fix(node-sdk): carry lastTurnReason through the SDK session summary
wbxl2000 f4dc3bb
fix(node-sdk): populate lastTurnReason on live SDK summaries
wbxl2000 a50d776
fix(agent-core-v2): carry the last turn outcome across session forks
wbxl2000 3902d01
fix(agent-core-v2): settle pending outcome writes before reading a fo…
wbxl2000 6dc33a1
fix(agent-core-v2): backfill restored outcomes into the session metadata
wbxl2000 87f0568
fix(agent-core-v2): never backfill restored cancellations
wbxl2000 6d24140
refactor(agent-core-v2): rename the outcome recorder to outcome mirror
wbxl2000 f02041c
fix(agent-core-v2): backfill without bumping recency; header-only com…
wbxl2000 c3c4507
fix(node-sdk): read the live outcome for resumed session summaries
wbxl2000 977e533
fix(agent-core-v2): confine the outcome backfill to pure resumes
wbxl2000 109cfed
fix(agent-core-v2): drain the session-index mirror before session tea…
wbxl2000 883ba17
docs(agent-core-v2): fold the metadata drain contract into the file h…
wbxl2000 a904ce3
chore: include the SDK package in the changeset; fold the drain note …
wbxl2000 faf73c7
fix(agent-core-v2): backfill restored cancellations too, quietly
wbxl2000 19634c7
fix(node-sdk): prefer the live outcome over the index in SDK listings
wbxl2000 8c511e6
fix(node-sdk): never read the metadata outcome for a live session
wbxl2000 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@moonshot-ai/kimi-code": patch | ||
| --- | ||
|
|
||
| Session listings keep how the last turn ended (completed, cancelled, or failed) across server restarts, so clients can mark previously failed sessions before they are opened. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
19 changes: 19 additions & 0 deletions
19
packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeRecorder.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| /** | ||
| * `sessionActivity` domain — `ISessionOutcomeRecorder` contract: persist the | ||
| * latest main-turn outcome into durable session metadata. | ||
| * | ||
| * The activity aggregate's `lastTurnReason` is live fold state, rebuilt per | ||
| * process; this recorder is the write side that lands terminal outcomes in | ||
| * the session's metadata document, so the session index (and therefore cold | ||
| * listings after a restart) keep reporting them. Session-scoped — one | ||
| * instance per session. | ||
| */ | ||
|
|
||
| import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; | ||
|
|
||
| export interface ISessionOutcomeRecorder { | ||
| readonly _serviceBrand: undefined; | ||
| } | ||
|
|
||
| export const ISessionOutcomeRecorder: ServiceIdentifier<ISessionOutcomeRecorder> = | ||
| createDecorator<ISessionOutcomeRecorder>('sessionOutcomeRecorder'); |
83 changes: 83 additions & 0 deletions
83
packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeRecorderService.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| /** | ||
| * `sessionActivity` domain — `ISessionOutcomeRecorder` implementation. | ||
| * | ||
| * Persists the main agent's terminal turn outcomes through `ISessionMetadata` | ||
| * (observed via `agentLifecycle` and the main agent's `eventBus`), so the | ||
| * session index keeps reporting them across restarts. Programmatic aborts — | ||
| * including scope-teardown cancels — are deliberately not persisted. Bound at | ||
| * Session scope. | ||
| */ | ||
|
|
||
| import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; | ||
| import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; | ||
| import { IEventBus } from '#/app/event/eventBus'; | ||
| import { | ||
| IAgentLifecycleService, | ||
| MAIN_AGENT_ID, | ||
| } from '#/session/agentLifecycle/agentLifecycle'; | ||
| import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; | ||
|
|
||
| import type { SessionTurnOutcome } from './sessionActivity'; | ||
| import { ISessionOutcomeRecorder } from './sessionOutcomeRecorder'; | ||
|
|
||
| export class SessionOutcomeRecorder extends Disposable implements ISessionOutcomeRecorder { | ||
| declare readonly _serviceBrand: undefined; | ||
|
|
||
| private lastPersisted: SessionTurnOutcome | undefined; | ||
| private mainSubscription: IDisposable | undefined; | ||
|
|
||
| constructor( | ||
| @IAgentLifecycleService private readonly agents: IAgentLifecycleService, | ||
| @ISessionMetadata private readonly metadata: ISessionMetadata, | ||
| ) { | ||
| super(); | ||
| this.attachMain(); | ||
| this._register(this.agents.onDidCreate((handle) => { | ||
| if (handle.id === MAIN_AGENT_ID) this.attachMain(); | ||
| })); | ||
| this._register({ | ||
| dispose: () => { | ||
| this.mainSubscription?.dispose(); | ||
| this.mainSubscription = undefined; | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| private attachMain(): void { | ||
| if (this.mainSubscription !== undefined) return; | ||
| const bus = this.agents.get(MAIN_AGENT_ID)?.accessor.get(IEventBus) as IEventBus | undefined; | ||
| if (bus === undefined) return; | ||
| this.mainSubscription = bus.subscribe('turn.ended', (event) => { | ||
|
wbxl2000 marked this conversation as resolved.
Outdated
|
||
| if (event.type !== 'turn.ended') return; | ||
| const reason = (event as { reason?: unknown }).reason; | ||
| const interruptReason = (event as { interruptReason?: unknown }).interruptReason; | ||
| if (reason === 'completed') { | ||
| this.persist('completed'); | ||
| return; | ||
| } | ||
| if (reason === 'failed' || reason === 'blocked') { | ||
| this.persist('failed'); | ||
| return; | ||
| } | ||
| if (reason === 'cancelled' && interruptReason === 'user_cancelled') { | ||
| this.persist('cancelled'); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| private persist(outcome: SessionTurnOutcome): void { | ||
| if (outcome === this.lastPersisted) return; | ||
| this.lastPersisted = outcome; | ||
| void this.metadata | ||
| .update({ lastTurnOutcome: outcome }) | ||
| .catch(() => {}); | ||
| } | ||
| } | ||
|
|
||
| registerScopedService( | ||
| LifecycleScope.Session, | ||
| ISessionOutcomeRecorder, | ||
| SessionOutcomeRecorder, | ||
| ScopeActivation.OnScopeCreated, | ||
| 'sessionActivity', | ||
| ); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
101 changes: 101 additions & 0 deletions
101
packages/agent-core-v2/test/session/sessionActivity/sessionOutcomeRecorder.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| import { describe, expect, it } from 'vitest'; | ||
|
|
||
| import { Emitter } from '#/_base/event'; | ||
| import type { DomainEvent } from '#/app/event/eventBus'; | ||
| import { IEventBus } from '#/app/event/eventBus'; | ||
| import type { IAgentScopeHandle } from '#/_base/di/scope'; | ||
| import type { SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; | ||
| import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; | ||
| import { | ||
| IAgentLifecycleService, | ||
| MAIN_AGENT_ID, | ||
| } from '#/session/agentLifecycle/agentLifecycle'; | ||
| import { SessionOutcomeRecorder } from '#/session/sessionActivity/sessionOutcomeRecorderService'; | ||
|
|
||
| class FakeBus { | ||
| private readonly handlers = new Map<string, Array<(e: DomainEvent) => void>>(); | ||
|
|
||
| publish(event: DomainEvent): void { | ||
| for (const h of this.handlers.get(event.type) ?? []) h(event); | ||
| } | ||
|
|
||
| subscribe(type: unknown, handler?: unknown) { | ||
| const list = this.handlers.get(type as string) ?? []; | ||
| const fn = handler as (e: DomainEvent) => void; | ||
| list.push(fn); | ||
| this.handlers.set(type as string, list); | ||
| return { dispose: () => this.handlers.set(type as string, list.filter((h) => h !== fn)) }; | ||
| } | ||
| } | ||
|
|
||
| describe('SessionOutcomeRecorder', () => { | ||
| function harness(opts?: { withMain?: boolean }) { | ||
| const withMain = opts?.withMain ?? true; | ||
| const bus = new FakeBus(); | ||
| const mainHandle = { | ||
| id: MAIN_AGENT_ID, | ||
| accessor: { get: (token: unknown) => (token === IEventBus ? bus : undefined) }, | ||
| } as unknown as IAgentScopeHandle; | ||
| const onDidCreate = new Emitter<IAgentScopeHandle>(); | ||
| let mainPresent = withMain; | ||
| const agents = { | ||
| get: (id: string) => (id === MAIN_AGENT_ID && mainPresent ? mainHandle : undefined), | ||
| onDidCreate: onDidCreate.event, | ||
| } as unknown as IAgentLifecycleService; | ||
| const writes: SessionMeta['lastTurnOutcome'][] = []; | ||
| const metadata = { | ||
| update: async (patch: { lastTurnOutcome?: SessionMeta['lastTurnOutcome'] }) => { | ||
| writes.push(patch.lastTurnOutcome); | ||
| }, | ||
| } as unknown as ISessionMetadata; | ||
| const recorder = new SessionOutcomeRecorder(agents, metadata); | ||
|
wbxl2000 marked this conversation as resolved.
Outdated
|
||
| return { | ||
| writes, | ||
| ended: (reason: string, interruptReason?: string) => | ||
| bus.publish({ type: 'turn.ended', reason, interruptReason } as unknown as DomainEvent), | ||
| arriveMain: () => { | ||
| mainPresent = true; | ||
| onDidCreate.fire(mainHandle); | ||
| }, | ||
| dispose: () => { | ||
| recorder.dispose(); | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| it('persists completed and failed outcomes from the main agent', () => { | ||
| const { writes, ended, dispose } = harness(); | ||
| ended('completed'); | ||
| ended('failed'); | ||
| ended('blocked'); // folds to 'failed', which the dedup then collapses | ||
| expect(writes).toEqual(['completed', 'failed']); | ||
| dispose(); | ||
| }); | ||
|
|
||
| it('persists a user stop but never a programmatic abort', () => { | ||
| const { writes, ended, dispose } = harness(); | ||
| ended('cancelled', 'user_cancelled'); | ||
| ended('cancelled', 'aborted'); | ||
| ended('cancelled', undefined); | ||
| expect(writes).toEqual(['cancelled']); | ||
| dispose(); | ||
| }); | ||
|
|
||
| it('dedupes repeated outcomes within the process', () => { | ||
| const { writes, ended, dispose } = harness(); | ||
| ended('failed'); | ||
| ended('failed'); | ||
| expect(writes).toEqual(['failed']); | ||
| dispose(); | ||
| }); | ||
|
|
||
| it('subscribes when the main agent appears after construction', () => { | ||
| const { writes, ended, arriveMain, dispose } = harness({ withMain: false }); | ||
| ended('failed'); | ||
| expect(writes).toEqual([]); | ||
| arriveMain(); | ||
| ended('failed'); | ||
| expect(writes).toEqual(['failed']); | ||
| dispose(); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.