@kuralle-agents/core 0.3.11 → 0.3.13

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.
@@ -48,6 +48,7 @@ export declare class Runtime {
48
48
  private readonly terminalHandoffTargets;
49
49
  private readonly hooks?;
50
50
  private readonly activeTurnAborts;
51
+ private readonly sessionMutex;
51
52
  constructor(config: HarnessConfig);
52
53
  run(opts: RunOptions): TurnHandle;
53
54
  stream(opts: RunOptions): TurnHandle;
@@ -15,6 +15,7 @@ import { loadRecordedSteps } from './durable/replay.js';
15
15
  import { markSessionOutcome } from './outcomeMarking.js';
16
16
  import { resolveAgentPolicies } from './policies/resolvePolicies.js';
17
17
  import { buildAutoRetrieveProvider, buildKnowledgeProvider, buildMemoryService, runMemoryIngest, } from './grounding/index.js';
18
+ import { SessionMutex } from './SessionMutex.js';
18
19
  export class Runtime {
19
20
  config;
20
21
  agentsById;
@@ -24,6 +25,7 @@ export class Runtime {
24
25
  terminalHandoffTargets;
25
26
  hooks;
26
27
  activeTurnAborts = new Map();
28
+ sessionMutex = new SessionMutex();
27
29
  constructor(config) {
28
30
  this.config = config;
29
31
  this.agentsById = indexAgents(config.agents);
@@ -89,6 +91,7 @@ export class Runtime {
89
91
  steps,
90
92
  toolExecutor,
91
93
  model,
94
+ controlModel: opened.agent.controlModel ?? model,
92
95
  abortSignal: abortController.signal,
93
96
  emit,
94
97
  refinementPolicies: policies.refinementPolicies,
@@ -201,10 +204,19 @@ export class Runtime {
201
204
  }
202
205
  return { text: collectAssistantText(runCtx.runState.messages), toolResults: [] };
203
206
  };
207
+ const gated = async () => {
208
+ const release = await this.sessionMutex.acquire(sessionId);
209
+ try {
210
+ return await execute();
211
+ }
212
+ finally {
213
+ release();
214
+ }
215
+ };
204
216
  return createTurnHandle({
205
217
  bus,
206
218
  abortController,
207
- run: execute,
219
+ run: gated,
208
220
  });
209
221
  }
210
222
  stream(opts) {
@@ -47,8 +47,7 @@ export class SessionMutex {
47
47
  // Set the new tail BEFORE awaiting — so the next concurrent caller
48
48
  // sees this promise in the chain
49
49
  this.locks.set(sessionId, newTail);
50
- // Wait for the previous lock holder to release
51
- await currentTail;
50
+ await currentTail.catch(() => { });
52
51
  return releaseFn;
53
52
  }
54
53
  /** Number of sessions currently locked. For testing/debugging. */
@@ -109,8 +109,7 @@ export class TextDriver {
109
109
  // voice are identical). The model's prose is discarded; the user-facing
110
110
  // question is emitted deterministically by the flow engine (CollectNode.ask).
111
111
  runExtraction(node, ctx) {
112
- const model = node.node.model ?? ctx.model;
113
- return runSilentExtraction(node, ctx, model, resolveMaxSteps(ctx.limits, this.maxSteps));
112
+ return runSilentExtraction(node, ctx, ctx.controlModel, resolveMaxSteps(ctx.limits, this.maxSteps));
114
113
  }
115
114
  async runStructured(node, ctx) {
116
115
  const base = composeSystem(ctx.baseInstructions, resolveInstructions(node.instructions, ctx.runState.state), ctx.runState.state);
@@ -125,10 +124,11 @@ export class TextDriver {
125
124
  .join(', ')}. Respond with only the chosen id, nothing else.`
126
125
  : base;
127
126
  const { object } = await generateObject({
128
- model: ctx.model,
127
+ model: ctx.controlModel,
129
128
  schema,
130
129
  system,
131
130
  messages: ctx.runState.messages,
131
+ temperature: 0,
132
132
  abortSignal: ctx.abortSignal,
133
133
  });
134
134
  return object;
@@ -75,10 +75,11 @@ export class VoiceDriver {
75
75
  .join(', ')}. Respond with only the chosen id, nothing else.`
76
76
  : base;
77
77
  const { object } = await generateObject({
78
- model: ctx.model,
78
+ model: ctx.controlModel,
79
79
  schema,
80
80
  system,
81
81
  messages: ctx.runState.messages,
82
+ temperature: 0,
82
83
  abortSignal: ctx.abortSignal,
83
84
  });
84
85
  return object;
@@ -89,7 +90,7 @@ export class VoiceDriver {
89
90
  // behavior to TextDriver — voice and text emit the same structural events; the
90
91
  // user-facing question is the deterministic CollectNode.ask, synthesized after.
91
92
  runExtraction(node, ctx) {
92
- return runSilentExtraction(node, ctx, ctx.model, resolveMaxSteps(ctx.limits, this.maxSteps));
93
+ return runSilentExtraction(node, ctx, ctx.controlModel, resolveMaxSteps(ctx.limits, this.maxSteps));
93
94
  }
94
95
  async awaitUser(ctx) {
95
96
  if (this.pendingBargeInInput != null) {
@@ -20,7 +20,14 @@ export async function runSilentExtraction(node, ctx, model, maxSteps) {
20
20
  const aiTools = resolveExtractionTools(node);
21
21
  const out = { text: '', toolResults: [] };
22
22
  for (let step = 0; step < maxSteps; step += 1) {
23
- const result = streamText({ model, system, messages, tools: aiTools, abortSignal: ctx.abortSignal });
23
+ const result = streamText({
24
+ model,
25
+ system,
26
+ messages,
27
+ tools: aiTools,
28
+ temperature: 0,
29
+ abortSignal: ctx.abortSignal,
30
+ });
24
31
  for await (const part of result.fullStream) {
25
32
  // Intentionally NOT handling 'text-delta' — extraction never speaks.
26
33
  if (part.type === 'error') {
@@ -1,19 +1,27 @@
1
1
  const PENDING_INPUT_KEY = '__v2_pendingUserInput';
2
+ function queue(session) {
3
+ const v = session.workingMemory[PENDING_INPUT_KEY];
4
+ if (Array.isArray(v))
5
+ return v;
6
+ if (typeof v === 'string' && v.length > 0)
7
+ return [v];
8
+ return [];
9
+ }
2
10
  export function setPendingUserInput(session, input) {
3
- session.workingMemory[PENDING_INPUT_KEY] = input;
11
+ session.workingMemory[PENDING_INPUT_KEY] = [...queue(session), input];
4
12
  }
5
13
  export function consumePendingUserInput(session) {
6
- const value = session.workingMemory[PENDING_INPUT_KEY];
7
- delete session.workingMemory[PENDING_INPUT_KEY];
8
- if (typeof value !== 'string' || value.length === 0) {
9
- throw new Error('No buffered user input for awaitUser');
10
- }
11
- return value;
14
+ const q = queue(session);
15
+ const next = q.shift() ?? '';
16
+ if (q.length === 0)
17
+ delete session.workingMemory[PENDING_INPUT_KEY];
18
+ else
19
+ session.workingMemory[PENDING_INPUT_KEY] = q;
20
+ return next;
12
21
  }
13
22
  export function peekPendingUserInput(session) {
14
- const value = session.workingMemory[PENDING_INPUT_KEY];
15
- return typeof value === 'string' ? value : undefined;
23
+ return queue(session)[0];
16
24
  }
17
25
  export function hasPendingUserInput(session) {
18
- return peekPendingUserInput(session) !== undefined;
26
+ return queue(session).length > 0;
19
27
  }
@@ -20,6 +20,7 @@ export interface CtxDeps {
20
20
  toolExecutor: EffectToolExecutor;
21
21
  hookRunner?: HookRunner;
22
22
  model: LanguageModel;
23
+ controlModel?: LanguageModel;
23
24
  refinementPolicies?: RefinementCapability[];
24
25
  validationPolicies?: ValidationCapability[];
25
26
  inputProcessors?: InputProcessor[];
@@ -99,6 +99,7 @@ function makeCtx(deps) {
99
99
  toolExecutor: deps.toolExecutor,
100
100
  hookRunner: deps.hookRunner ?? {},
101
101
  model: deps.model,
102
+ controlModel: deps.controlModel ?? deps.model,
102
103
  refinementPolicies: deps.refinementPolicies ?? [],
103
104
  validationPolicies: deps.validationPolicies ?? [],
104
105
  inputProcessors: deps.inputProcessors ?? [],
@@ -21,7 +21,7 @@ export async function hostLoop(options) {
21
21
  const selection = await select({
22
22
  agent,
23
23
  run,
24
- model: agent.routing?.model ?? agent.model ?? ctx.model,
24
+ model: agent.routing?.model ?? ctx.controlModel,
25
25
  alwaysRoute,
26
26
  });
27
27
  if (selection.kind === 'enterFlow') {
@@ -33,6 +33,7 @@ export async function selectHostTarget(options) {
33
33
  const { object } = await generateObject({
34
34
  model,
35
35
  schema: selectionSchema,
36
+ temperature: 0,
36
37
  system: 'You are an internal routing classifier. Choose exactly one action. ' +
37
38
  'Output schema fields only — never user-facing prose. ' +
38
39
  'Prefer route when a route condition clearly matches; else enterFlow when a flow description matches an uncompleted flow; else keep. Never re-enter a completed flow.',
@@ -14,6 +14,10 @@ export interface AgentConfig {
14
14
  description?: string;
15
15
  instructions?: Instructions;
16
16
  model?: LanguageModel;
17
+ /** Optional model for the control path (routing, decide, extraction), run at
18
+ * temperature 0 for determinism. Defaults to `model` (the speaker) when unset.
19
+ * Set this to pin control to a reliable provider independent of the speaker. */
20
+ controlModel?: LanguageModel;
17
21
  tools?: ToolSet;
18
22
  effectTools?: Record<string, AnyTool>;
19
23
  /** Safe, always-available tools made model-visible in EVERY speaking node turn
@@ -51,6 +51,8 @@ export interface RunContext {
51
51
  toolExecutor: EffectToolExecutor;
52
52
  hookRunner: HookRunner;
53
53
  model: LanguageModel;
54
+ /** Control-path model (routing, decide, extraction) at temperature 0. */
55
+ controlModel: LanguageModel;
54
56
  refinementPolicies: RefinementCapability[];
55
57
  validationPolicies: ValidationCapability[];
56
58
  inputProcessors: InputProcessor[];
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "url": "git+https://github.com/kuralle/kuralle-agents.git",
7
7
  "directory": "packages/kuralle-core"
8
8
  },
9
- "version": "0.3.11",
9
+ "version": "0.3.13",
10
10
  "description": "A framework for structured conversational AI agents",
11
11
  "publishConfig": {
12
12
  "access": "public"
@@ -97,7 +97,7 @@
97
97
  "dotenv": "^16.4.0",
98
98
  "typescript": "^5.3.0",
99
99
  "zod": "^3.23.0",
100
- "@kuralle-agents/realtime-audio": "0.3.11"
100
+ "@kuralle-agents/realtime-audio": "0.3.13"
101
101
  },
102
102
  "dependencies": {
103
103
  "chrono-node": "^2.6.0",