Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/context/src/context/layer-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,8 +480,14 @@ export async function initLayers({ layers, ctx, storage, store }: InitLayersPara
* and `store()` hooks — are mirrored durably; `store()` is not special.
* 'execution' scope is excluded: its scope key rotates each run, so there is
* nothing durable to mirror.
*
* Exported (rather than private to `initLayers`) because a host that carries
* layer state forward across executions — warm hydration, see
* `AgentHarness.ensureLayersInit` — has to re-point write-through at the new
* executionId WITHOUT re-running `init`, which is the whole point of carrying
* state forward.
*/
function registerDurableTargets({
export function registerDurableTargets({
layers,
ctx,
storage,
Expand Down
273 changes: 255 additions & 18 deletions packages/core/src/harness/agent-harness.ts

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions packages/core/src/harness/deps/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ export {
recallLayers,
recallLayersAtomic,
recallLayersEventual,
registerDurableTargets,
resolveLayerTools,
resolveScopeKey,
runAppendPipeline,
storeLayers,
} from '@noetic-tools/context';
1 change: 1 addition & 0 deletions packages/core/src/harness/deps/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export {
} from '../../runtime/durable/step-ledger';
export type { EventBroadcaster } from '../../runtime/event-broadcaster';
export { createInMemoryStorage } from '../../runtime/in-memory-storage';
export { ItemLogImpl } from '../../runtime/item-log-impl';
export type { QueuedMessage } from '../../runtime/message-queue';
export { SessionRunner } from '../../runtime/session-runner';
export {
Expand Down
21 changes: 16 additions & 5 deletions packages/core/src/runtime/context-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,13 @@ export class ContextImpl implements Context<ContextData> {
harness: AgentHarnessContract;
parent?: Context;
items?: Item[];
/**
* Share an existing log instead of building a fresh one from `items`.
* The session runner passes its session-owned log here so every turn in a
* thread appends to ONE log — no copy-forward/copy-back per turn.
* Mutually exclusive with `items`.
*/
itemLog?: ItemLogImpl;
state?: unknown;
threadId?: string;
resourceId?: string;
Expand Down Expand Up @@ -200,13 +207,17 @@ export class ContextImpl implements Context<ContextData> {
};
this._broadcaster = opts._broadcaster;

const log = new ItemLogImpl(this.itemSchemas);
if (opts.items) {
for (const item of opts.items) {
log.append(item);
if (opts.itemLog) {
this.itemLog = opts.itemLog;
} else {
const log = new ItemLogImpl(this.itemSchemas);
if (opts.items) {
for (const item of opts.items) {
log.append(item);
}
}
this.itemLog = log;
}
this.itemLog = log;

// Join the parent's abort cascade last, so the child is fully constructed
// before an already-aborted parent aborts it.
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/runtime/item-log-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,23 @@ export class ItemLogImpl implements ItemLog {
this._items.push(this.itemSchemas.parse(item));
this._frozenCache = null;
}

/** @internal Current length — used as a rollback watermark by the session runner. */
get length(): number {
return this._items.length;
}

/**
* @internal Roll the log back to a previously-captured watermark. Used ONLY
* by the session runner to discard a failed/aborted turn's partial items so
* a shared session log preserves the same "failed turns leave no trace"
* semantics the copy-based history had.
*/
truncateTo(watermark: number): void {
if (watermark < 0 || watermark >= this._items.length) {
return;
}
this._items.length = watermark;
this._frozenCache = null;
}
}
74 changes: 44 additions & 30 deletions packages/core/src/runtime/session-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ export interface SessionRunnerOpts {
readonly agentName: string;
readonly runTurn: RunTurnFn;
readonly createContext: CreateContextFn;
/**
* Roll back session-owned state after a failed/aborted turn. With a shared
* session log (single-owner history), a failed turn's partial items must be
* discarded explicitly to preserve the "failed turns leave no trace"
* contract the old copy-back gave for free.
*/
readonly rollbackTurn?: () => void;
}

//#endregion
Expand Down Expand Up @@ -111,6 +118,7 @@ export class SessionRunner {
private readonly agentName: string;
private readonly runTurn: RunTurnFn;
private readonly createContext: CreateContextFn;
private readonly rollbackTurn?: () => void;

private status: HarnessStatus = {
kind: 'idle',
Expand Down Expand Up @@ -139,6 +147,7 @@ export class SessionRunner {
this.agentName = opts.agentName;
this.runTurn = opts.runTurn;
this.createContext = opts.createContext;
this.rollbackTurn = opts.rollbackTurn;

this.queue.subscribe(() => {
this.kick();
Expand Down Expand Up @@ -245,37 +254,39 @@ export class SessionRunner {
// the harness's createContext callback receives Item[] directly and
// seeds the context without re-converting.
const items = mergeInputsToItems(messages);
const ctx = this.createContext(items, turnId, messages);
this.currentCtx = ctx;

emitFrameworkEvent({
broadcaster: this.broadcaster,
agentName: this.agentName,
eventType: 'turn_started',
data: {
turnId,
messageIds: messages.map((m) => m.id),
},
});
// Input items never appear in the SDK stream — item_appended is what
// carries them into getItemStream (and any other log-faithful consumer).
for (const item of items) {
let ctx: Context | undefined;

try {
ctx = this.createContext(items, turnId, messages);
this.currentCtx = ctx;

emitFrameworkEvent({
broadcaster: this.broadcaster,
agentName: this.agentName,
eventType: 'item_appended',
eventType: 'turn_started',
data: {
item,
turnId,
messageIds: messages.map((m) => m.id),
},
});
}
// Input items never appear in the SDK stream — item_appended is what
// carries them into getItemStream (and any other log-faithful consumer).
for (const item of items) {
emitFrameworkEvent({
broadcaster: this.broadcaster,
agentName: this.agentName,
eventType: 'item_appended',
data: {
item,
},
});
}

const turn: TurnContext = {
turnId,
session: this,
};
const turn: TurnContext = {
turnId,
session: this,
};

try {
const text = await this.runTurn(ctx, turn, controller.signal);
const response = buildResponse(text, ctx);
this.lastResponse = response;
Expand All @@ -299,6 +310,7 @@ export class SessionRunner {
} catch (err: unknown) {
const error = err instanceof Error ? err : new Error(String(err));
this.lastError = error;
this.rollbackTurn?.();
emitFrameworkEvent({
broadcaster: this.broadcaster,
agentName: this.agentName,
Expand All @@ -312,14 +324,16 @@ export class SessionRunner {
} finally {
// Accumulate the turn's token accounting whatever the outcome — an
// aborted turn still consumed whatever the model billed before the cut.
this.totalInputTokens += ctx.tokens.input;
this.totalOutputTokens += ctx.tokens.output;
// Only ever leave `undefined` behind when NO turn reported a figure —
// a turn that reported 0 must surface as 0, not "unreported".
if (ctx.tokens.cached !== undefined) {
this.totalCachedTokens = (this.totalCachedTokens ?? 0) + ctx.tokens.cached;
if (ctx) {
this.totalInputTokens += ctx.tokens.input;
this.totalOutputTokens += ctx.tokens.output;
// Only ever leave `undefined` behind when NO turn reported a figure —
// a turn that reported 0 must surface as 0, not "unreported".
if (ctx.tokens.cached !== undefined) {
this.totalCachedTokens = (this.totalCachedTokens ?? 0) + ctx.tokens.cached;
}
this.totalCost += ctx.cost;
}
this.totalCost += ctx.cost;
this.currentCtx = undefined;
this.currentController = undefined;
this.status = {
Expand Down
Loading
Loading