@kuralle-agents/core 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -0
- package/dist/capabilities/LivePromptAssembler.js +1 -0
- package/dist/flow/collectDigression.d.ts +5 -2
- package/dist/flow/collectDigression.js +48 -10
- package/dist/flow/collectUntilComplete.js +9 -1
- package/dist/flow/extraction.d.ts +1 -0
- package/dist/flow/extraction.js +4 -0
- package/dist/flow/runFlow.js +63 -5
- package/dist/index.d.ts +6 -1
- package/dist/index.js +4 -0
- package/dist/prompts/PromptBuilder.js +7 -0
- package/dist/prompts/types.d.ts +2 -0
- package/dist/runtime/InMemoryRetrievalCache.d.ts +25 -0
- package/dist/runtime/InMemoryRetrievalCache.js +59 -0
- package/dist/runtime/KnowledgeProvider.js +6 -1
- package/dist/runtime/Runtime.d.ts +44 -0
- package/dist/runtime/Runtime.js +196 -13
- package/dist/runtime/TokenAccumulator.d.ts +7 -0
- package/dist/runtime/TokenAccumulator.js +7 -0
- package/dist/runtime/TraceRecorder.d.ts +33 -0
- package/dist/runtime/TraceRecorder.js +290 -0
- package/dist/runtime/channels/TextDriver.js +45 -22
- package/dist/runtime/channels/executeModelTool.d.ts +8 -1
- package/dist/runtime/channels/executeModelTool.js +70 -1
- package/dist/runtime/channels/inputBuffer.d.ts +1 -0
- package/dist/runtime/channels/inputBuffer.js +9 -0
- package/dist/runtime/closeRun.js +11 -8
- package/dist/runtime/compaction.d.ts +2 -0
- package/dist/runtime/compaction.js +3 -2
- package/dist/runtime/ctx.js +110 -57
- package/dist/runtime/durable/RunStore.d.ts +16 -0
- package/dist/runtime/durable/RunStore.js +6 -0
- package/dist/runtime/durable/SessionRunStore.d.ts +7 -2
- package/dist/runtime/durable/SessionRunStore.js +136 -34
- package/dist/runtime/durable/idempotency.d.ts +1 -0
- package/dist/runtime/durable/idempotency.js +3 -0
- package/dist/runtime/durable/replay.js +7 -2
- package/dist/runtime/durable/types.d.ts +11 -0
- package/dist/runtime/goals.d.ts +18 -0
- package/dist/runtime/goals.js +158 -0
- package/dist/runtime/grounding/knowledge.js +1 -1
- package/dist/runtime/handoffContinuation.d.ts +20 -0
- package/dist/runtime/handoffContinuation.js +30 -0
- package/dist/runtime/handoffOscillation.d.ts +6 -0
- package/dist/runtime/handoffOscillation.js +17 -0
- package/dist/runtime/hostLoop.js +11 -0
- package/dist/runtime/index.d.ts +3 -1
- package/dist/runtime/index.js +1 -0
- package/dist/runtime/openRun.d.ts +2 -0
- package/dist/runtime/openRun.js +46 -17
- package/dist/runtime/policies/limits.d.ts +1 -0
- package/dist/runtime/policies/limits.js +3 -0
- package/dist/runtime/select.d.ts +5 -1
- package/dist/runtime/select.js +30 -1
- package/dist/runtime/turnTokenUsage.d.ts +28 -0
- package/dist/runtime/turnTokenUsage.js +70 -0
- package/dist/session/SessionStore.d.ts +6 -0
- package/dist/session/SessionStore.js +12 -1
- package/dist/session/stores/MemoryStore.d.ts +1 -1
- package/dist/session/stores/MemoryStore.js +16 -2
- package/dist/session/testing.d.ts +6 -1
- package/dist/session/testing.js +34 -0
- package/dist/session/utils.d.ts +3 -0
- package/dist/session/utils.js +23 -0
- package/dist/tools/effect/ToolExecutor.js +4 -1
- package/dist/tools/effect/defineTool.d.ts +2 -0
- package/dist/tools/effect/defineTool.js +2 -0
- package/dist/tracing/MemoryTraceStore.d.ts +16 -0
- package/dist/tracing/MemoryTraceStore.js +56 -0
- package/dist/tracing/OtelTraceSink.d.ts +127 -0
- package/dist/tracing/OtelTraceSink.js +101 -0
- package/dist/tracing/TraceStore.d.ts +19 -0
- package/dist/tracing/TraceStore.js +32 -0
- package/dist/tracing/index.d.ts +3 -0
- package/dist/tracing/index.js +3 -0
- package/dist/tracing/testing.d.ts +3 -0
- package/dist/tracing/testing.js +45 -0
- package/dist/types/agentConfig.d.ts +2 -1
- package/dist/types/channel.d.ts +14 -1
- package/dist/types/effectTool.d.ts +4 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/run-context.d.ts +14 -1
- package/dist/types/session.d.ts +10 -0
- package/dist/types/stream.d.ts +8 -0
- package/dist/types/trace.d.ts +50 -0
- package/dist/types/trace.js +1 -0
- package/guides/EXAMPLE_VERIFICATION.md +4 -4
- package/package.json +13 -4
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { generateObject } from 'ai';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
export const GOALS_KEY = '__goals';
|
|
4
|
+
const goalPatchSchema = z.object({
|
|
5
|
+
add: z
|
|
6
|
+
.array(z.object({
|
|
7
|
+
topic: z.string(),
|
|
8
|
+
note: z.union([z.string(), z.null()]),
|
|
9
|
+
}))
|
|
10
|
+
.default([]),
|
|
11
|
+
resolve: z.array(z.string()).default([]),
|
|
12
|
+
});
|
|
13
|
+
function isTrackedGoal(value) {
|
|
14
|
+
if (!value || typeof value !== 'object') {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
const goal = value;
|
|
18
|
+
return (typeof goal.topic === 'string' &&
|
|
19
|
+
(goal.status === 'open' || goal.status === 'resolved') &&
|
|
20
|
+
typeof goal.lastTurn === 'number');
|
|
21
|
+
}
|
|
22
|
+
function normalizeTopic(topic) {
|
|
23
|
+
return topic.trim().toLowerCase();
|
|
24
|
+
}
|
|
25
|
+
export function getGoals(state) {
|
|
26
|
+
const raw = state[GOALS_KEY];
|
|
27
|
+
if (!Array.isArray(raw)) {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
return raw.filter(isTrackedGoal);
|
|
31
|
+
}
|
|
32
|
+
export function addGoal(state, topic, lastTurn = 0, note) {
|
|
33
|
+
const trimmed = topic.trim();
|
|
34
|
+
if (!trimmed) {
|
|
35
|
+
return state;
|
|
36
|
+
}
|
|
37
|
+
const goals = getGoals(state);
|
|
38
|
+
const key = normalizeTopic(trimmed);
|
|
39
|
+
const existingIndex = goals.findIndex((goal) => normalizeTopic(goal.topic) === key);
|
|
40
|
+
if (existingIndex >= 0) {
|
|
41
|
+
const next = [...goals];
|
|
42
|
+
const existing = next[existingIndex];
|
|
43
|
+
next[existingIndex] = {
|
|
44
|
+
...existing,
|
|
45
|
+
topic: trimmed,
|
|
46
|
+
status: 'open',
|
|
47
|
+
lastTurn,
|
|
48
|
+
note: note ?? existing.note,
|
|
49
|
+
};
|
|
50
|
+
return { ...state, [GOALS_KEY]: next };
|
|
51
|
+
}
|
|
52
|
+
const entry = { topic: trimmed, status: 'open', lastTurn };
|
|
53
|
+
if (note) {
|
|
54
|
+
entry.note = note;
|
|
55
|
+
}
|
|
56
|
+
return { ...state, [GOALS_KEY]: [...goals, entry] };
|
|
57
|
+
}
|
|
58
|
+
export function resolveGoal(state, topic, lastTurn = 0) {
|
|
59
|
+
const trimmed = topic.trim();
|
|
60
|
+
if (!trimmed) {
|
|
61
|
+
return state;
|
|
62
|
+
}
|
|
63
|
+
const key = normalizeTopic(trimmed);
|
|
64
|
+
const goals = getGoals(state);
|
|
65
|
+
let changed = false;
|
|
66
|
+
const next = goals.map((goal) => {
|
|
67
|
+
if (normalizeTopic(goal.topic) !== key) {
|
|
68
|
+
return goal;
|
|
69
|
+
}
|
|
70
|
+
changed = true;
|
|
71
|
+
return { ...goal, status: 'resolved', lastTurn };
|
|
72
|
+
});
|
|
73
|
+
if (!changed) {
|
|
74
|
+
return state;
|
|
75
|
+
}
|
|
76
|
+
return { ...state, [GOALS_KEY]: next };
|
|
77
|
+
}
|
|
78
|
+
export function listOpenGoals(state) {
|
|
79
|
+
return getGoals(state)
|
|
80
|
+
.filter((goal) => goal.status === 'open')
|
|
81
|
+
.map((goal) => goal.topic);
|
|
82
|
+
}
|
|
83
|
+
export function projectGoalsPrompt(goals) {
|
|
84
|
+
const open = goals.filter((goal) => goal.status === 'open');
|
|
85
|
+
if (open.length === 0) {
|
|
86
|
+
return '';
|
|
87
|
+
}
|
|
88
|
+
const parts = open.map((goal) => goal.note ? `${goal.topic} (${goal.status}, ${goal.note})` : `${goal.topic} (${goal.status})`);
|
|
89
|
+
return `Open threads: ${parts.join('; ')}`;
|
|
90
|
+
}
|
|
91
|
+
export function projectGoalsPromptFromState(state) {
|
|
92
|
+
return projectGoalsPrompt(getGoals(state));
|
|
93
|
+
}
|
|
94
|
+
function readSessionTurn(state) {
|
|
95
|
+
const value = state['__ariaSessionTurn'];
|
|
96
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
97
|
+
}
|
|
98
|
+
function latestExchange(messages) {
|
|
99
|
+
let assistant = '';
|
|
100
|
+
let user = '';
|
|
101
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
102
|
+
const message = messages[index];
|
|
103
|
+
if (!assistant && message?.role === 'assistant' && typeof message.content === 'string') {
|
|
104
|
+
assistant = message.content;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (assistant && message?.role === 'user' && typeof message.content === 'string') {
|
|
108
|
+
user = message.content;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (!user || !assistant) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return { user, assistant };
|
|
116
|
+
}
|
|
117
|
+
function applyGoalPatch(state, patch) {
|
|
118
|
+
let next = state;
|
|
119
|
+
const turn = readSessionTurn(state);
|
|
120
|
+
for (const topic of patch.resolve) {
|
|
121
|
+
next = resolveGoal(next, topic, turn);
|
|
122
|
+
}
|
|
123
|
+
for (const entry of patch.add) {
|
|
124
|
+
const note = entry.note ?? undefined;
|
|
125
|
+
next = addGoal(next, entry.topic, turn, note);
|
|
126
|
+
}
|
|
127
|
+
return next;
|
|
128
|
+
}
|
|
129
|
+
export async function updateGoalsFromTurn(ctx, model) {
|
|
130
|
+
const exchange = latestExchange(ctx.runState.messages);
|
|
131
|
+
if (!exchange) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const currentGoals = getGoals(ctx.session.workingMemory);
|
|
135
|
+
const goalsSummary = currentGoals.length === 0
|
|
136
|
+
? 'none'
|
|
137
|
+
: currentGoals
|
|
138
|
+
.map((goal) => goal.note
|
|
139
|
+
? `${goal.topic} [${goal.status}] — ${goal.note}`
|
|
140
|
+
: `${goal.topic} [${goal.status}]`)
|
|
141
|
+
.join('\n');
|
|
142
|
+
const { object } = await generateObject({
|
|
143
|
+
model,
|
|
144
|
+
schema: goalPatchSchema,
|
|
145
|
+
temperature: 0,
|
|
146
|
+
system: 'You track conversational threads (goals/topics the user may circle back to). ' +
|
|
147
|
+
'Given the latest exchange and current tracked threads, return only schema fields. ' +
|
|
148
|
+
'Add a topic when the user opens a new thread or revisits one not yet tracked. ' +
|
|
149
|
+
'Resolve a topic when the exchange clearly completes or abandons it. ' +
|
|
150
|
+
'Keep topics short labels (1-4 words). Do not invent threads unrelated to the exchange.',
|
|
151
|
+
prompt: `Current tracked threads:\n${goalsSummary}\n\n` +
|
|
152
|
+
`Latest user message:\n${exchange.user}\n\n` +
|
|
153
|
+
`Latest assistant message:\n${exchange.assistant}\n\n` +
|
|
154
|
+
'Return add[] for new/reopened open topics and resolve[] for completed topics.',
|
|
155
|
+
});
|
|
156
|
+
const patched = applyGoalPatch(ctx.session.workingMemory, object);
|
|
157
|
+
ctx.session.workingMemory = patched;
|
|
158
|
+
}
|
|
@@ -42,7 +42,7 @@ export function buildAutoRetrieveProvider(provider, agent) {
|
|
|
42
42
|
retrieve: async (ctx, scope) => {
|
|
43
43
|
const query = scope?.query ?? latestUserMessage(ctx);
|
|
44
44
|
const merged = scope?.knowledge ? { ...overrides, ...scope.knowledge } : overrides;
|
|
45
|
-
const cache =
|
|
45
|
+
const cache = ctx.retrievalCache;
|
|
46
46
|
const { results, events } = await provider.retrieve(query || ' ', cache, merged, false);
|
|
47
47
|
for (const event of events) {
|
|
48
48
|
ctx.emit(event);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Silent handoff: a transfer between agents should read as one continuous
|
|
3
|
+
* assistant to the user. The transfer itself is already a silent control tool
|
|
4
|
+
* call, but the TARGET agent's own instructions may tell it to introduce itself
|
|
5
|
+
* ("Bill here"), which leaks the switch. On a silent handoff we append a strong
|
|
6
|
+
* continuation directive to the target's system prompt so it does not greet or
|
|
7
|
+
* re-introduce — mirroring the OpenAI Agents SDK's `nest_handoff_history`
|
|
8
|
+
* context-note approach. Off (visible handoff) leaves the target's instructions
|
|
9
|
+
* untouched.
|
|
10
|
+
*/
|
|
11
|
+
import type { Instructions } from '../types/agentConfig.js';
|
|
12
|
+
export declare const HANDOFF_CONTINUATION_DIRECTIVE: string;
|
|
13
|
+
/**
|
|
14
|
+
* Append the continuation directive to a target agent's instructions on a silent
|
|
15
|
+
* handoff. Handles every `Instructions` form: a string is suffixed; a (sync)
|
|
16
|
+
* function is wrapped so the directive is appended to its resolved text; an
|
|
17
|
+
* `AgentPrompt` object is left untouched (it cannot be safely suffixed, and is
|
|
18
|
+
* rare as a base layer); `undefined` yields the directive alone.
|
|
19
|
+
*/
|
|
20
|
+
export declare function applyHandoffContinuation(base: Instructions | undefined): Instructions;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const HANDOFF_CONTINUATION_DIRECTIVE = 'You are seamlessly continuing an ongoing conversation with the same user — this is ' +
|
|
2
|
+
'not a new conversation. Do NOT greet the user, introduce yourself, state your name ' +
|
|
3
|
+
'or role, say "hello", or mention any transfer, connection, or that a different ' +
|
|
4
|
+
'assistant is now helping. Ignore any earlier instruction to open with a greeting or ' +
|
|
5
|
+
'self-introduction. Continue as one uninterrupted assistant and answer the request directly.';
|
|
6
|
+
const SUFFIX = `\n\n[Handoff continuation]\n${HANDOFF_CONTINUATION_DIRECTIVE}`;
|
|
7
|
+
/**
|
|
8
|
+
* Append the continuation directive to a target agent's instructions on a silent
|
|
9
|
+
* handoff. Handles every `Instructions` form: a string is suffixed; a (sync)
|
|
10
|
+
* function is wrapped so the directive is appended to its resolved text; an
|
|
11
|
+
* `AgentPrompt` object is left untouched (it cannot be safely suffixed, and is
|
|
12
|
+
* rare as a base layer); `undefined` yields the directive alone.
|
|
13
|
+
*/
|
|
14
|
+
export function applyHandoffContinuation(base) {
|
|
15
|
+
if (typeof base === 'string') {
|
|
16
|
+
return `${base}${SUFFIX}`;
|
|
17
|
+
}
|
|
18
|
+
if (typeof base === 'function') {
|
|
19
|
+
return (ctx) => {
|
|
20
|
+
const inner = base(ctx);
|
|
21
|
+
if (typeof inner !== 'string') {
|
|
22
|
+
// resolveInstructions only supports sync-string functions; match that contract.
|
|
23
|
+
throw new Error('handoff continuation: instructions function must return a string synchronously');
|
|
24
|
+
}
|
|
25
|
+
return `${inner}${SUFFIX}`;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
// AgentPrompt object → preserve as-is (do not drop the persona); undefined → directive only.
|
|
29
|
+
return base ?? HANDOFF_CONTINUATION_DIRECTIVE;
|
|
30
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** True when the recent handoff history shows A↔B oscillation at/above `threshold` hops
|
|
2
|
+
* between the same unordered pair, counting the pending from→to as the latest hop. */
|
|
3
|
+
export declare function isHandoffOscillating(history: Array<{
|
|
4
|
+
from: string;
|
|
5
|
+
to: string;
|
|
6
|
+
}>, pendingFrom: string, pendingTo: string, threshold: number): boolean;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** True when the recent handoff history shows A↔B oscillation at/above `threshold` hops
|
|
2
|
+
* between the same unordered pair, counting the pending from→to as the latest hop. */
|
|
3
|
+
export function isHandoffOscillating(history, pendingFrom, pendingTo, threshold) {
|
|
4
|
+
const pairKey = (a, b) => [a, b].sort().join('\0');
|
|
5
|
+
const target = pairKey(pendingFrom, pendingTo);
|
|
6
|
+
let count = 1; // the pending hop
|
|
7
|
+
for (let i = history.length - 1; i >= 0; i -= 1) {
|
|
8
|
+
const h = history[i];
|
|
9
|
+
if (!h)
|
|
10
|
+
break;
|
|
11
|
+
if (pairKey(h.from, h.to) === target)
|
|
12
|
+
count += 1;
|
|
13
|
+
else
|
|
14
|
+
break; // only CONSECUTIVE same-pair hops count as oscillation
|
|
15
|
+
}
|
|
16
|
+
return count >= threshold;
|
|
17
|
+
}
|
package/dist/runtime/hostLoop.js
CHANGED
|
@@ -9,6 +9,7 @@ import { hasHostControlTargets } from './hostControlTools.js';
|
|
|
9
9
|
import { isValidControl, resolveHostControl, startHostControlGuard, } from './hostControlGuard.js';
|
|
10
10
|
import { resolveDispatchMode, isAdvisoryDispatch } from './dispatchMode.js';
|
|
11
11
|
import { adaptHostSelect } from './hostClassifyAdapter.js';
|
|
12
|
+
import { persistTurnUsageFromTurn } from './turnTokenUsage.js';
|
|
12
13
|
export async function hostLoop(options) {
|
|
13
14
|
const { agent, run, driver, ctx } = options;
|
|
14
15
|
const classify = options.classify ??
|
|
@@ -67,6 +68,12 @@ async function runActiveFlow(flow, run, driver, ctx, agent) {
|
|
|
67
68
|
ctx.resetCallsites();
|
|
68
69
|
const result = await runFlow(flow, run, driver, ctx, agent);
|
|
69
70
|
if (result.kind === 'handoff') {
|
|
71
|
+
// A handoff fired from inside a flow: the source flow is abandoned for this turn.
|
|
72
|
+
// Clear the active-flow pointers so the target agent does not try (and fail) to
|
|
73
|
+
// resume a flow that belongs to the source agent (G17).
|
|
74
|
+
run.activeFlow = undefined;
|
|
75
|
+
run.activeNode = undefined;
|
|
76
|
+
await ctx.runStore.putRunState(run);
|
|
70
77
|
return { kind: 'handoff', to: result.to, reason: result.reason };
|
|
71
78
|
}
|
|
72
79
|
if (result.kind === 'awaitingUser') {
|
|
@@ -111,12 +118,16 @@ async function runFreeConversation(agent, run, driver, ctx, classify) {
|
|
|
111
118
|
resolved.hostControl = { dispatchMode, advisoryDispatch };
|
|
112
119
|
}
|
|
113
120
|
const turn = await driver.runAgentTurn(resolved, ctx);
|
|
121
|
+
await persistTurnUsageFromTurn(ctx, turn);
|
|
114
122
|
if (turn.control && isValidControl(turn.control, agent, run)) {
|
|
115
123
|
emitHostGuardTelemetry(ctx, { invoked: false, reason: 'main-control' });
|
|
116
124
|
return await executeHostControl(agent, run, driver, ctx, turn.control);
|
|
117
125
|
}
|
|
118
126
|
if (turn.text.trim()) {
|
|
119
127
|
emitHostGuardTelemetry(ctx, { invoked: false, reason: 'answered' });
|
|
128
|
+
if (turn.toolMessages?.length) {
|
|
129
|
+
run.messages = [...run.messages, ...turn.toolMessages];
|
|
130
|
+
}
|
|
120
131
|
const message = { role: 'assistant', content: turn.text };
|
|
121
132
|
run.messages = [...run.messages, message];
|
|
122
133
|
await ctx.runStore.putRunState(run);
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
export { createRuntime, Runtime, type HarnessConfig, type RunOptions, } from './Runtime.js';
|
|
1
|
+
export { createRuntime, Runtime, type HarnessConfig, type RunOptions, type TracingConfig, } from './Runtime.js';
|
|
2
2
|
export type { RuntimeLike } from './RuntimeLike.js';
|
|
3
|
+
export { TraceRecorder, runOnce, type TraceRecorderOptions } from './TraceRecorder.js';
|
|
4
|
+
export type { AgentSpan, AgentTrace, SpanKind } from '../types/trace.js';
|
|
3
5
|
export { SessionWorkingMemory } from './WorkingMemory.js';
|
|
4
6
|
export { TextDriver, VoiceDriver } from './channels/index.js';
|
|
5
7
|
export type { VoiceDriverConfig } from './channels/index.js';
|
package/dist/runtime/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { createRuntime, Runtime, } from './Runtime.js';
|
|
2
|
+
export { TraceRecorder, runOnce } from './TraceRecorder.js';
|
|
2
3
|
export { SessionWorkingMemory } from './WorkingMemory.js';
|
|
3
4
|
export { TextDriver, VoiceDriver } from './channels/index.js';
|
|
4
5
|
// Pending-input buffer helpers — required by custom ChannelDriver authors to
|
|
@@ -21,6 +21,8 @@ export interface OpenRunOptions {
|
|
|
21
21
|
seedMessages?: ModelMessage[];
|
|
22
22
|
historyDelta?: ModelMessage[];
|
|
23
23
|
signalDelivery?: SignalDelivery;
|
|
24
|
+
/** Stable key for this inbound user message; duplicate webhook retries are ignored (H2). */
|
|
25
|
+
idempotencyKey?: string;
|
|
24
26
|
transcriptionModel?: TranscriptionModel;
|
|
25
27
|
defaultAgentId: string;
|
|
26
28
|
sessionStore: SessionStore;
|
package/dist/runtime/openRun.js
CHANGED
|
@@ -3,6 +3,8 @@ import { transcribeAudioParts } from './userInput.js';
|
|
|
3
3
|
import { setPendingUserInput } from './channels/inputBuffer.js';
|
|
4
4
|
import { SessionRunStore } from './durable/SessionRunStore.js';
|
|
5
5
|
import { recordSignalDelivery } from './durable/replay.js';
|
|
6
|
+
import { resetTurnCount } from './policies/limits.js';
|
|
7
|
+
import { mutateSessionWithRetry } from '../session/utils.js';
|
|
6
8
|
export function sessionDerivedRunId(sessionId) {
|
|
7
9
|
return sessionId;
|
|
8
10
|
}
|
|
@@ -26,16 +28,18 @@ export async function openRun(agentsById, options) {
|
|
|
26
28
|
};
|
|
27
29
|
await runStore.initRun(runState);
|
|
28
30
|
if (initialMessages.length > 0) {
|
|
29
|
-
session.
|
|
30
|
-
|
|
31
|
+
await mutateSessionWithRetry(options.sessionStore, session.id, (latest) => {
|
|
32
|
+
latest.messages = [...initialMessages];
|
|
33
|
+
});
|
|
31
34
|
}
|
|
32
35
|
}
|
|
33
36
|
if (options.historyDelta?.length) {
|
|
34
37
|
runState.messages = [...runState.messages, ...options.historyDelta];
|
|
35
38
|
runState.updatedAt = Date.now();
|
|
36
39
|
await runStore.putRunState(runState);
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
await mutateSessionWithRetry(options.sessionStore, session.id, (latest) => {
|
|
41
|
+
latest.messages = [...latest.messages, ...options.historyDelta];
|
|
42
|
+
});
|
|
39
43
|
}
|
|
40
44
|
if (options.signalDelivery) {
|
|
41
45
|
await recordSignalDelivery(runStore, runState, options.signalDelivery);
|
|
@@ -53,22 +57,47 @@ export async function openRun(agentsById, options) {
|
|
|
53
57
|
const hasInput = typeof effectiveInput === 'string'
|
|
54
58
|
? effectiveInput.length > 0
|
|
55
59
|
: Array.isArray(effectiveInput) && effectiveInput.length > 0;
|
|
60
|
+
const isResume = Boolean(options.signalDelivery);
|
|
61
|
+
const isFlowContinuation = Boolean(runState.activeFlow);
|
|
62
|
+
const isFreshLogicalRun = (hasInput || Boolean(options.wake)) && !isResume && !isFlowContinuation;
|
|
63
|
+
if (isFreshLogicalRun) {
|
|
64
|
+
runState.runEpoch = (runState.runEpoch ?? 0) + 1;
|
|
65
|
+
await runStore.pruneStepsBeforeEpoch(runId, runState.runEpoch);
|
|
66
|
+
resetTurnCount(runState);
|
|
67
|
+
if (Array.isArray(runState.state.__completedFlows)) {
|
|
68
|
+
runState.state.__completedFlows = [];
|
|
69
|
+
}
|
|
70
|
+
runState.updatedAt = Date.now();
|
|
71
|
+
await runStore.putRunState(runState);
|
|
72
|
+
}
|
|
56
73
|
if (hasInput && effectiveInput !== undefined) {
|
|
74
|
+
if (options.idempotencyKey) {
|
|
75
|
+
const processed = runState.processedInboundKeys ?? [];
|
|
76
|
+
if (processed.includes(options.idempotencyKey)) {
|
|
77
|
+
const agent = agentsById.get(runState.activeAgentId);
|
|
78
|
+
if (!agent) {
|
|
79
|
+
throw new Error(`Unknown activeAgentId "${runState.activeAgentId}"`);
|
|
80
|
+
}
|
|
81
|
+
const latestSession = (await options.sessionStore.get(options.sessionId)) ?? session;
|
|
82
|
+
return { session: latestSession, runState, runStore, agent };
|
|
83
|
+
}
|
|
84
|
+
runState.processedInboundKeys = [...processed, options.idempotencyKey];
|
|
85
|
+
}
|
|
57
86
|
runState.updatedAt = Date.now();
|
|
58
87
|
if (runState.activeFlow) {
|
|
59
88
|
await runStore.putRunState(runState);
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
89
|
+
await mutateSessionWithRetry(options.sessionStore, session.id, (latest) => {
|
|
90
|
+
setPendingUserInput(latest, effectiveInput);
|
|
91
|
+
});
|
|
63
92
|
}
|
|
64
93
|
else {
|
|
65
94
|
const userMessage = { role: 'user', content: effectiveInput };
|
|
66
95
|
runState.messages = [...runState.messages, userMessage];
|
|
67
96
|
runState.updatedAt = Date.now();
|
|
68
97
|
await runStore.putRunState(runState);
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
98
|
+
await mutateSessionWithRetry(options.sessionStore, session.id, (latest) => {
|
|
99
|
+
latest.messages = [...latest.messages, userMessage];
|
|
100
|
+
});
|
|
72
101
|
}
|
|
73
102
|
}
|
|
74
103
|
if (options.wake && !hasInput) {
|
|
@@ -84,18 +113,18 @@ export async function openRun(agentsById, options) {
|
|
|
84
113
|
runState.messages = [...runState.messages, wakeMessage];
|
|
85
114
|
runState.updatedAt = Date.now();
|
|
86
115
|
await runStore.putRunState(runState);
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
116
|
+
await mutateSessionWithRetry(options.sessionStore, session.id, (latest) => {
|
|
117
|
+
latest.messages = [...latest.messages, wakeMessage];
|
|
118
|
+
});
|
|
90
119
|
}
|
|
91
120
|
const agent = agentsById.get(runState.activeAgentId);
|
|
92
121
|
if (!agent) {
|
|
93
122
|
throw new Error(`Unknown activeAgentId "${runState.activeAgentId}"`);
|
|
94
123
|
}
|
|
95
|
-
const latestSession =
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
124
|
+
const latestSession = await mutateSessionWithRetry(options.sessionStore, session.id, (latest) => {
|
|
125
|
+
latest.currentAgent = runState.activeAgentId;
|
|
126
|
+
latest.activeAgentId = runState.activeAgentId;
|
|
127
|
+
});
|
|
99
128
|
return { session: latestSession, runState, runStore, agent };
|
|
100
129
|
}
|
|
101
130
|
async function loadOrCreateSession(options) {
|
|
@@ -4,5 +4,6 @@ export declare class LimitsExceededError extends Error {
|
|
|
4
4
|
constructor(message: string);
|
|
5
5
|
}
|
|
6
6
|
export declare function incrementTurnCount(run: RunState): number;
|
|
7
|
+
export declare function resetTurnCount(run: RunState): void;
|
|
7
8
|
export declare function assertWithinTurnLimit(run: RunState, limits?: Limits): void;
|
|
8
9
|
export declare function resolveMaxSteps(limits: Limits | undefined, fallback: number): number;
|
|
@@ -15,6 +15,9 @@ export function incrementTurnCount(run) {
|
|
|
15
15
|
run.updatedAt = Date.now();
|
|
16
16
|
return next;
|
|
17
17
|
}
|
|
18
|
+
export function resetTurnCount(run) {
|
|
19
|
+
run.state[TURN_COUNT_KEY] = 0;
|
|
20
|
+
}
|
|
18
21
|
export function assertWithinTurnLimit(run, limits) {
|
|
19
22
|
const maxTurns = limits?.maxTurns;
|
|
20
23
|
if (maxTurns == null) {
|
package/dist/runtime/select.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { LanguageModel } from 'ai';
|
|
1
|
+
import type { LanguageModel, ModelMessage } from 'ai';
|
|
2
2
|
import type { AgentConfig } from '../types/agentConfig.js';
|
|
3
3
|
import type { Flow } from '../types/flow.js';
|
|
4
4
|
import type { RunState } from './durable/types.js';
|
|
@@ -20,12 +20,15 @@ export interface HostGuardVerdict {
|
|
|
20
20
|
reason?: string;
|
|
21
21
|
confidence?: number;
|
|
22
22
|
}
|
|
23
|
+
export declare const DEFAULT_CLASSIFIER_CONTEXT_MESSAGES = 6;
|
|
23
24
|
export interface ClassifyHostOptions {
|
|
24
25
|
agent: AgentConfig;
|
|
25
26
|
run: RunState;
|
|
26
27
|
model: LanguageModel;
|
|
27
28
|
allowKeep: boolean;
|
|
28
29
|
excludeFlowNames?: string[];
|
|
30
|
+
/** How many recent messages to include in the classifier prompt. Default: 6. */
|
|
31
|
+
contextMessageLimit?: number;
|
|
29
32
|
}
|
|
30
33
|
export declare function classifyHostTarget(options: ClassifyHostOptions): Promise<HostGuardVerdict>;
|
|
31
34
|
/** @deprecated Use classifyHostTarget — kept as alias for test injection. */
|
|
@@ -33,4 +36,5 @@ export declare function selectHostTarget(options: Omit<ClassifyHostOptions, 'all
|
|
|
33
36
|
alwaysRoute?: boolean;
|
|
34
37
|
}): Promise<HostSelection>;
|
|
35
38
|
export declare function verdictToSelection(verdict: HostGuardVerdict, agent: AgentConfig): HostSelection | undefined;
|
|
39
|
+
export declare function formatRecentConversation(messages: ModelMessage[], limit?: number): string;
|
|
36
40
|
export { availableHostFlows };
|
package/dist/runtime/select.js
CHANGED
|
@@ -14,11 +14,13 @@ const dispatcherSchema = z.object({
|
|
|
14
14
|
agentId: z.union([z.string(), z.null()]),
|
|
15
15
|
reason: z.union([z.string(), z.null()]),
|
|
16
16
|
});
|
|
17
|
+
export const DEFAULT_CLASSIFIER_CONTEXT_MESSAGES = 6;
|
|
17
18
|
export async function classifyHostTarget(options) {
|
|
18
19
|
const { agent, run, model, allowKeep } = options;
|
|
19
20
|
const flows = agent.flows ?? [];
|
|
20
21
|
const routes = agent.routes ?? [];
|
|
21
22
|
const latestUser = latestUserMessage(run.messages);
|
|
23
|
+
const recentContext = formatRecentConversation(run.messages, options.contextMessageLimit ?? DEFAULT_CLASSIFIER_CONTEXT_MESSAGES);
|
|
22
24
|
if (!latestUser) {
|
|
23
25
|
return { action: 'keep', confidence: 1 };
|
|
24
26
|
}
|
|
@@ -51,7 +53,9 @@ export async function classifyHostTarget(options) {
|
|
|
51
53
|
system: 'You are an internal routing classifier. Choose exactly one action. ' +
|
|
52
54
|
'Output schema fields only — never user-facing prose. ' +
|
|
53
55
|
'Reason over semantic descriptions only; never match keywords or substrings.',
|
|
54
|
-
prompt:
|
|
56
|
+
prompt: (recentContext
|
|
57
|
+
? `Recent conversation:\n${recentContext}\n\n`
|
|
58
|
+
: `User message:\n${latestUser}\n\n`) +
|
|
55
59
|
(completedFlows.length > 0 ? `Completed flows: ${completedFlows.join(', ')}\n\n` : '') +
|
|
56
60
|
(flowLines ? `Available flows:\n${flowLines}\n\n` : '') +
|
|
57
61
|
(routeLines ? `Routes:\n${routeLines}\n\n` : '') +
|
|
@@ -159,6 +163,31 @@ function formatRouteLine(route, index) {
|
|
|
159
163
|
const target = route.agent ? `agent "${route.agent}"` : route.flow ? `flow "${route.flow}"` : 'keep';
|
|
160
164
|
return `- route ${index + 1} → ${target} when: ${route.when}`;
|
|
161
165
|
}
|
|
166
|
+
function formatMessageContent(message) {
|
|
167
|
+
if (typeof message.content === 'string') {
|
|
168
|
+
return message.content;
|
|
169
|
+
}
|
|
170
|
+
if (Array.isArray(message.content)) {
|
|
171
|
+
return message.content
|
|
172
|
+
.filter((part) => part.type === 'text')
|
|
173
|
+
.map((part) => part.text)
|
|
174
|
+
.join('');
|
|
175
|
+
}
|
|
176
|
+
return '';
|
|
177
|
+
}
|
|
178
|
+
export function formatRecentConversation(messages, limit = DEFAULT_CLASSIFIER_CONTEXT_MESSAGES) {
|
|
179
|
+
const slice = messages.slice(-limit);
|
|
180
|
+
return slice
|
|
181
|
+
.map((message) => {
|
|
182
|
+
const content = formatMessageContent(message);
|
|
183
|
+
if (!content.trim()) {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
return `${message.role}: ${content}`;
|
|
187
|
+
})
|
|
188
|
+
.filter((line) => line != null)
|
|
189
|
+
.join('\n');
|
|
190
|
+
}
|
|
162
191
|
function latestUserMessage(messages) {
|
|
163
192
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
164
193
|
const message = messages[i];
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { TurnResult } from '../types/channel.js';
|
|
2
|
+
import type { RunContext } from '../types/run-context.js';
|
|
3
|
+
export declare const TOKEN_USAGE_STATE_KEY = "__tokenUsage";
|
|
4
|
+
export declare const LAST_PROMPT_TOKENS_KEY = "__lastPromptTokens";
|
|
5
|
+
export interface PersistedTokenUsage {
|
|
6
|
+
inputTokens: number;
|
|
7
|
+
outputTokens: number;
|
|
8
|
+
totalTokens: number;
|
|
9
|
+
cacheReadTokens?: number;
|
|
10
|
+
}
|
|
11
|
+
/** Record real turn usage onto run state (survives across turns via persistence). */
|
|
12
|
+
export declare function persistTurnUsageFromTurn(ctx: RunContext, turn: TurnResult): Promise<void>;
|
|
13
|
+
export declare function readLastPromptTokens(state: Record<string, unknown>): number | undefined;
|
|
14
|
+
/** The session-cumulative usage persisted on run state (or undefined before any turn). */
|
|
15
|
+
export declare function readCumulativeUsage(state: Record<string, unknown>): PersistedTokenUsage | undefined;
|
|
16
|
+
export interface TraceTurnUsage {
|
|
17
|
+
/** Input tokens consumed by THIS turn (cumulative delta since the turn opened). */
|
|
18
|
+
inputTokens?: number;
|
|
19
|
+
/** Output tokens generated by THIS turn (cumulative delta since the turn opened). */
|
|
20
|
+
outputTokens?: number;
|
|
21
|
+
/** Context-window occupancy — the last prompt's token count (not a per-turn delta). */
|
|
22
|
+
contextTokens?: number;
|
|
23
|
+
}
|
|
24
|
+
/** Per-turn token usage for a trace's `done` event. `baseline` is the cumulative
|
|
25
|
+
* usage captured when the turn OPENED, so input/output are strictly this turn's
|
|
26
|
+
* consumption (correct for cost attribution — never the running session total).
|
|
27
|
+
* `contextTokens` is the current window occupancy, which is inherently a snapshot. */
|
|
28
|
+
export declare function computeTurnTraceUsage(baseline: PersistedTokenUsage | undefined, state: Record<string, unknown>): TraceTurnUsage;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { TokenAccumulator } from './TokenAccumulator.js';
|
|
2
|
+
export const TOKEN_USAGE_STATE_KEY = '__tokenUsage';
|
|
3
|
+
export const LAST_PROMPT_TOKENS_KEY = '__lastPromptTokens';
|
|
4
|
+
function readPersistedUsage(state) {
|
|
5
|
+
const saved = state[TOKEN_USAGE_STATE_KEY];
|
|
6
|
+
if (!saved || typeof saved !== 'object') {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
const usage = saved;
|
|
10
|
+
if (typeof usage.inputTokens !== 'number' ||
|
|
11
|
+
typeof usage.outputTokens !== 'number' ||
|
|
12
|
+
typeof usage.totalTokens !== 'number') {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
return usage;
|
|
16
|
+
}
|
|
17
|
+
function hydrateAccumulator(state) {
|
|
18
|
+
const acc = new TokenAccumulator();
|
|
19
|
+
const saved = readPersistedUsage(state);
|
|
20
|
+
if (saved) {
|
|
21
|
+
acc.restoreCumulative(saved);
|
|
22
|
+
}
|
|
23
|
+
return acc;
|
|
24
|
+
}
|
|
25
|
+
/** Record real turn usage onto run state (survives across turns via persistence). */
|
|
26
|
+
export async function persistTurnUsageFromTurn(ctx, turn) {
|
|
27
|
+
if (!turn.usage) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const acc = hydrateAccumulator(ctx.runState.state);
|
|
31
|
+
const turnIndex = acc.turns.length + 1;
|
|
32
|
+
acc.record({
|
|
33
|
+
turn: turnIndex,
|
|
34
|
+
inputTokens: turn.usage.inputTokens,
|
|
35
|
+
outputTokens: turn.usage.outputTokens,
|
|
36
|
+
totalTokens: turn.usage.totalTokens,
|
|
37
|
+
cacheReadTokens: turn.usage.cacheReadTokens,
|
|
38
|
+
latencyMs: 0,
|
|
39
|
+
});
|
|
40
|
+
ctx.runState.state[TOKEN_USAGE_STATE_KEY] = acc.cumulative;
|
|
41
|
+
ctx.runState.state[LAST_PROMPT_TOKENS_KEY] =
|
|
42
|
+
turn.usage.contextTokens ?? turn.usage.inputTokens;
|
|
43
|
+
ctx.runState.updatedAt = Date.now();
|
|
44
|
+
await ctx.runStore.putRunState(ctx.runState);
|
|
45
|
+
}
|
|
46
|
+
export function readLastPromptTokens(state) {
|
|
47
|
+
const value = state[LAST_PROMPT_TOKENS_KEY];
|
|
48
|
+
return typeof value === 'number' ? value : undefined;
|
|
49
|
+
}
|
|
50
|
+
/** The session-cumulative usage persisted on run state (or undefined before any turn). */
|
|
51
|
+
export function readCumulativeUsage(state) {
|
|
52
|
+
return readPersistedUsage(state);
|
|
53
|
+
}
|
|
54
|
+
/** Per-turn token usage for a trace's `done` event. `baseline` is the cumulative
|
|
55
|
+
* usage captured when the turn OPENED, so input/output are strictly this turn's
|
|
56
|
+
* consumption (correct for cost attribution — never the running session total).
|
|
57
|
+
* `contextTokens` is the current window occupancy, which is inherently a snapshot. */
|
|
58
|
+
export function computeTurnTraceUsage(baseline, state) {
|
|
59
|
+
const contextTokens = readLastPromptTokens(state);
|
|
60
|
+
const now = readPersistedUsage(state);
|
|
61
|
+
if (!now) {
|
|
62
|
+
return contextTokens === undefined ? {} : { contextTokens };
|
|
63
|
+
}
|
|
64
|
+
const base = baseline ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
65
|
+
return {
|
|
66
|
+
inputTokens: Math.max(0, now.inputTokens - base.inputTokens),
|
|
67
|
+
outputTokens: Math.max(0, now.outputTokens - base.outputTokens),
|
|
68
|
+
...(contextTokens === undefined ? {} : { contextTokens }),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { Session } from '../types/index.js';
|
|
2
2
|
import type { AuditListOptions, ConversationAuditEntry } from '../audit/types.js';
|
|
3
|
+
export declare class StaleWriteError extends Error {
|
|
4
|
+
readonly sessionId: string;
|
|
5
|
+
readonly expectedVersion: number;
|
|
6
|
+
readonly actualVersion: number;
|
|
7
|
+
constructor(sessionId: string, expectedVersion: number, actualVersion: number);
|
|
8
|
+
}
|
|
3
9
|
export interface SessionListWindow {
|
|
4
10
|
from?: Date;
|
|
5
11
|
to?: Date;
|
|
@@ -1 +1,12 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export class StaleWriteError extends Error {
|
|
2
|
+
sessionId;
|
|
3
|
+
expectedVersion;
|
|
4
|
+
actualVersion;
|
|
5
|
+
constructor(sessionId, expectedVersion, actualVersion) {
|
|
6
|
+
super(`Stale write for session ${sessionId}: expected version ${expectedVersion}, stored version is ${actualVersion}`);
|
|
7
|
+
this.name = 'StaleWriteError';
|
|
8
|
+
this.sessionId = sessionId;
|
|
9
|
+
this.expectedVersion = expectedVersion;
|
|
10
|
+
this.actualVersion = actualVersion;
|
|
11
|
+
}
|
|
12
|
+
}
|