@kuralle-agents/core 0.7.1 → 0.8.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.
@@ -1,5 +1,6 @@
1
1
  import { runCollectDigression } from './collectDigression.js';
2
2
  import { hasPendingUserInput } from '../runtime/channels/inputBuffer.js';
3
+ import { userInputToText } from '../runtime/userInput.js';
3
4
  import { resolveCollectExtractionNode } from './nodeBuilders.js';
4
5
  import { computeMissingFields, createExtractionSubmitTool, getCollectData, incrementCollectTurns, emitExtractionTelemetry, mergeTurnExtraction, projectCollectData, schemaSatisfied, } from './extraction.js';
5
6
  import { normalizeTransition } from './normalizeTransition.js';
@@ -127,8 +128,10 @@ function mergeExtractionFromTurn(node, run, turn, ctx) {
127
128
  function peekLatestUserMessage(run) {
128
129
  for (let i = run.messages.length - 1; i >= 0; i -= 1) {
129
130
  const message = run.messages[i];
130
- if (message?.role === 'user' && typeof message.content === 'string') {
131
- return message.content;
131
+ if (message?.role === 'user') {
132
+ const text = userInputToText(message.content);
133
+ if (text)
134
+ return text;
132
135
  }
133
136
  }
134
137
  return undefined;
@@ -1,6 +1,7 @@
1
1
  import { getFlowPark } from './collectDigression.js';
2
2
  import { parseConfirmation } from './confirmParse.js';
3
3
  import { hasPendingUserInput } from '../runtime/channels/inputBuffer.js';
4
+ import { userInputToText } from '../runtime/userInput.js';
4
5
  import { collectUntilComplete } from './collectUntilComplete.js';
5
6
  import { isActionNode, isCollectNode, isDecideNode, isReplyNode, } from './nodeKinds.js';
6
7
  import { normalizeTransition, resolveNodeRef } from './normalizeTransition.js';
@@ -53,8 +54,10 @@ function appendUserMessage(run, input) {
53
54
  function latestUserText(run) {
54
55
  for (let i = run.messages.length - 1; i >= 0; i -= 1) {
55
56
  const message = run.messages[i];
56
- if (message?.role === 'user' && typeof message.content === 'string') {
57
- return message.content;
57
+ if (message?.role === 'user') {
58
+ const text = userInputToText(message.content);
59
+ if (text)
60
+ return text;
58
61
  }
59
62
  }
60
63
  return '';
@@ -67,7 +70,7 @@ async function dispatchConfirmGate(node, run, driver, ctx) {
67
70
  let input = '';
68
71
  if (hasPendingUserInput(ctx.session)) {
69
72
  const signal = await driver.awaitUser(ctx);
70
- input = signal.input;
73
+ input = userInputToText(signal.input);
71
74
  appendUserMessage(run, signal.input);
72
75
  }
73
76
  else {
package/dist/index.d.ts CHANGED
@@ -55,6 +55,8 @@ export { resolveAgentWorkspace, type ResolvedAgentWorkspace, } from './runtime/r
55
55
  export { DEFAULT_CONTEXT_BUDGET, VOICE_CONTEXT_BUDGET, computeMessageHistoryBudget, truncateToTokenBudget, formatMemoryWithBudget, estimateTokenCount, ContextBudget, } from './runtime/ContextBudget.js';
56
56
  export type { ContextBudgetConfig } from './runtime/ContextBudget.js';
57
57
  export { TokenAccumulator } from './runtime/TokenAccumulator.js';
58
+ export { applyPromptCache, applyAnthropicCacheControl, buildOpenAIResponsesProviderOptions, isAnthropicLanguageModel, isOpenAIResponsesModel, } from './runtime/promptCache.js';
59
+ export type { AnthropicCacheTtl, OpenAIResponsesCompactOptions } from './runtime/promptCache.js';
58
60
  export { handoffFilters, removeToolHistory, keepRecentMessages, removeKeys, composeFilters, } from './runtime/handoffFilters.js';
59
61
  export type { HandoffInputFilter, HandoffInputData, HandoffInputResult, } from './runtime/handoffFilters.js';
60
62
  export { DefaultToolExecutor, DefaultConversationState, DefaultConversationEventLog, DefaultAgentStateController, createFoundation, } from './foundation/index.js';
@@ -88,10 +90,12 @@ export type { HarnessStreamPart } from './types/stream.js';
88
90
  export { harnessToUIMessageStream } from './ai-sdk/uiMessageStream.js';
89
91
  export type { KuralleMetadata, KuralleDataParts, KuralleUIMessage, } from './ai-sdk/uiMessageStream.js';
90
92
  export type { ChoiceOption, ResolvedSelection } from './types/selection.js';
91
- export type { RunState, StepRecord } from './runtime/durable/types.js';
93
+ export type { RunState, StepRecord, SignalDelivery, SessionDurableRuns, PersistedRun, } from './runtime/durable/types.js';
94
+ export { DURABLE_RUNS_KEY } from './runtime/durable/types.js';
92
95
  export type { RunStore } from './runtime/durable/RunStore.js';
93
96
  export type { ChannelDriver, TextDriver } from './runtime/channels/index.js';
94
97
  export type { TurnResult } from './types/channel.js';
95
98
  export type { RunContext } from './types/run-context.js';
96
99
  export { createRuntime, Runtime, type HarnessConfig, type RunOptions, } from './runtime/Runtime.js';
97
100
  export type { RuntimeLike } from './runtime/RuntimeLike.js';
101
+ export { userInputToText, hasMediaParts, transcribeAudioParts, type UserInputContent, } from './runtime/userInput.js';
package/dist/index.js CHANGED
@@ -34,6 +34,7 @@ export { wireWorkingMemory, loadWorkingMemoryBlocks, formatWorkingMemorySection,
34
34
  export { resolveAgentWorkspace, } from './runtime/resolveAgentWorkspace.js';
35
35
  export { DEFAULT_CONTEXT_BUDGET, VOICE_CONTEXT_BUDGET, computeMessageHistoryBudget, truncateToTokenBudget, formatMemoryWithBudget, estimateTokenCount, ContextBudget, } from './runtime/ContextBudget.js';
36
36
  export { TokenAccumulator } from './runtime/TokenAccumulator.js';
37
+ export { applyPromptCache, applyAnthropicCacheControl, buildOpenAIResponsesProviderOptions, isAnthropicLanguageModel, isOpenAIResponsesModel, } from './runtime/promptCache.js';
37
38
  export { handoffFilters, removeToolHistory, keepRecentMessages, removeKeys, composeFilters, } from './runtime/handoffFilters.js';
38
39
  export { DefaultToolExecutor, DefaultConversationState, DefaultConversationEventLog, DefaultAgentStateController, createFoundation, } from './foundation/index.js';
39
40
  export { CapabilityHost, ExtractionCapability, GuardrailCapability, AutoRetrieveCapability, PassThroughRefinement, PassThroughValidation, toGeminiDeclarations, toAISDKTools, } from './capabilities/index.js';
@@ -46,4 +47,6 @@ export { SkillsCapability, wireAgentSkills, collectRegisteredNames, validateSkil
46
47
  export { buildToolSet, toolToAiSdk, wrapAiSdkTool, ToolApprovalDeniedError, ToolTimeoutError, } from './tools/effect/index.js';
47
48
  export { parseConfirmation } from './flow/confirmParse.js';
48
49
  export { harnessToUIMessageStream } from './ai-sdk/uiMessageStream.js';
50
+ export { DURABLE_RUNS_KEY } from './runtime/durable/types.js';
49
51
  export { createRuntime, Runtime, } from './runtime/Runtime.js';
52
+ export { userInputToText, hasMediaParts, transcribeAudioParts, } from './runtime/userInput.js';
@@ -1,4 +1,5 @@
1
- import type { LanguageModel, ModelMessage } from 'ai';
1
+ import type { LanguageModel, ModelMessage, TranscriptionModel } from 'ai';
2
+ import type { UserInputContent } from './userInput.js';
2
3
  import type { Session } from '../types/session.js';
3
4
  import type { SessionStore } from '../session/SessionStore.js';
4
5
  import type { AuditListOptions, ConversationAuditEntry } from '../audit/types.js';
@@ -31,10 +32,17 @@ export interface HarnessConfig {
31
32
  memoryService?: V1MemoryService;
32
33
  /** Default store for `agent.memory.workingMemory` when `workingMemory.store` is omitted. */
33
34
  defaultWorkingMemoryStore?: PersistentMemoryStore;
35
+ /**
36
+ * Optional AI SDK transcription model. When set, inbound audio file parts (voice
37
+ * notes) are transcribed to text before the model turn — so voice input works on
38
+ * text-only models. When omitted, audio parts pass through to audio-capable models.
39
+ */
40
+ transcriptionModel?: TranscriptionModel;
34
41
  }
35
42
  export interface RunOptions {
36
43
  sessionId?: string;
37
- input?: string;
44
+ /** The user turn: plain text, or AI SDK multimodal content (text + file/image/audio parts). */
45
+ input?: UserInputContent;
38
46
  selection?: ResolvedSelection;
39
47
  userId?: string;
40
48
  agentId?: string;
@@ -60,6 +60,7 @@ export class Runtime {
60
60
  seedMessages: opts.seedMessages,
61
61
  historyDelta: opts.historyDelta,
62
62
  signalDelivery: opts.signalDelivery,
63
+ transcriptionModel: this.config.transcriptionModel,
63
64
  defaultAgentId: this.config.defaultAgentId,
64
65
  sessionStore: this.sessionStore,
65
66
  });
@@ -9,6 +9,7 @@ import { resolveMaxSteps } from '../policies/limits.js';
9
9
  import { speakWithHostControl } from './streaming/hostControlSpeak.js';
10
10
  import { resolveStreamMode } from './streaming/mode.js';
11
11
  import { appendGatherBlocks, resolveNodeGatherScope, runGatherPhase } from '../grounding/index.js';
12
+ import { applyPromptCache } from '../promptCache.js';
12
13
  import { isFlowTransitionControlTool } from '../../flow/flowControlTools.js';
13
14
  import { resolveStructuredDecide } from '../../flow/choiceMatch.js';
14
15
  export class TextDriver {
@@ -50,12 +51,14 @@ export class TextDriver {
50
51
  const source = {
51
52
  async *[Symbol.asyncIterator]() {
52
53
  for (let step = 0; step < maxSteps; step += 1) {
54
+ const cached = applyPromptCache(model, ctx.session.id, messages);
53
55
  const result = streamText({
54
56
  model,
55
57
  system,
56
- messages,
58
+ messages: cached.messages,
57
59
  tools: aiTools,
58
60
  abortSignal: ctx.abortSignal,
61
+ ...(cached.providerOptions ? { providerOptions: cached.providerOptions } : {}),
59
62
  });
60
63
  for await (const part of result.fullStream) {
61
64
  if (part.type === 'text-delta') {
@@ -2,6 +2,7 @@ import { streamText } from 'ai';
2
2
  import { executeModelToolCall, toolResultMessage } from './executeModelTool.js';
3
3
  import { buildToolSet } from '../../tools/effect/index.js';
4
4
  import { buildNodePrompt, composeSystem } from '../../flow/nodeBuilders.js';
5
+ import { applyPromptCache } from '../promptCache.js';
5
6
  /**
6
7
  * Shared, NON-SPEAKING field extraction for `collect` nodes, used by every
7
8
  * ChannelDriver so text and voice behave identically. It runs the model with the
@@ -20,13 +21,15 @@ export async function runSilentExtraction(node, ctx, model, maxSteps) {
20
21
  const aiTools = resolveExtractionTools(node);
21
22
  const out = { text: '', toolResults: [] };
22
23
  for (let step = 0; step < maxSteps; step += 1) {
24
+ const cached = applyPromptCache(model, ctx.session.id, messages);
23
25
  const result = streamText({
24
26
  model,
25
27
  system,
26
- messages,
28
+ messages: cached.messages,
27
29
  tools: aiTools,
28
30
  temperature: 0,
29
31
  abortSignal: ctx.abortSignal,
32
+ ...(cached.providerOptions ? { providerOptions: cached.providerOptions } : {}),
30
33
  });
31
34
  for await (const part of result.fullStream) {
32
35
  // Intentionally NOT handling 'text-delta' — extraction never speaks.
@@ -1,5 +1,6 @@
1
1
  import type { Session } from '../../types/session.js';
2
- export declare function setPendingUserInput(session: Session, input: string): void;
3
- export declare function consumePendingUserInput(session: Session): string;
4
- export declare function peekPendingUserInput(session: Session): string | undefined;
2
+ import type { UserInputContent } from '../userInput.js';
3
+ export declare function setPendingUserInput(session: Session, input: UserInputContent): void;
4
+ export declare function consumePendingUserInput(session: Session): UserInputContent;
5
+ export declare function peekPendingUserInput(session: Session): UserInputContent | undefined;
5
6
  export declare function hasPendingUserInput(session: Session): boolean;
@@ -112,6 +112,15 @@ function makeCtx(deps) {
112
112
  bargeIn: deps.bargeIn,
113
113
  abortSignal: deps.abortSignal,
114
114
  turnInputConsumed: false,
115
+ // Rebase durable effect callsites to 0. Called at flow entry so a flow's
116
+ // effects (and any suspend/resume callsite) are anchored to the flow itself —
117
+ // identical whether the flow was entered fresh after an answering turn (which
118
+ // may have consumed callsites via enter_flow / tool calls) or re-entered on
119
+ // resume (where that answering turn does not re-run). Without this, a suspend's
120
+ // recorded callsite would not match on resume and the run would re-suspend.
121
+ resetCallsites: () => {
122
+ effectOrdinal = 0;
123
+ },
115
124
  tool: async (name, args, options) => {
116
125
  // needsApproval gate: a tool flagged `needsApproval` must be approved by a human
117
126
  // before it runs. Approval is a durable pause (the `__approval` signal); on resume
@@ -59,6 +59,12 @@ async function runAnsweringAgent(agent, run, driver, ctx, classify) {
59
59
  async function runActiveFlow(flow, run, driver, ctx, agent) {
60
60
  incrementTurnCount(run);
61
61
  assertWithinTurnLimit(run, ctx.limits);
62
+ // Anchor durable effect callsites to the flow. On a fresh entry the answering
63
+ // turn may have consumed callsites (enter_flow / tool calls); on resume that
64
+ // turn does not re-run. Rebasing here makes the flow's callsites (and any
65
+ // suspend/resume key) identical across both paths, so a resumed run does not
66
+ // re-suspend on a callsite mismatch.
67
+ ctx.resetCallsites();
62
68
  const result = await runFlow(flow, run, driver, ctx, agent);
63
69
  if (result.kind === 'handoff') {
64
70
  return { kind: 'handoff', to: result.to, reason: result.reason };
@@ -4,4 +4,5 @@ export { SessionWorkingMemory } from './WorkingMemory.js';
4
4
  export { TextDriver, VoiceDriver } from './channels/index.js';
5
5
  export type { VoiceDriverConfig } from './channels/index.js';
6
6
  export { setPendingUserInput, consumePendingUserInput, peekPendingUserInput, hasPendingUserInput, } from './channels/index.js';
7
+ export { userInputToText, hasMediaParts, transcribeAudioParts, type UserInputContent, } from './userInput.js';
7
8
  export { isAbortSignal, type InterruptionEvent, type AbortOptions, type CancellationReason, } from '../types/index.js';
@@ -5,4 +5,8 @@ export { TextDriver, VoiceDriver } from './channels/index.js';
5
5
  // implement awaitUser the same FIFO-aware way the built-in drivers do (the
6
6
  // buffer is an ordered queue since 0.3.13/H3, not a single slot).
7
7
  export { setPendingUserInput, consumePendingUserInput, peekPendingUserInput, hasPendingUserInput, } from './channels/index.js';
8
+ // Multimodal user input — `UserInputContent` is the runtime's accepted user-turn
9
+ // shape (text or AI SDK file/image/audio parts). Helpers project to text and
10
+ // transcribe audio so ingress adapters (web/messaging) can build it uniformly.
11
+ export { userInputToText, hasMediaParts, transcribeAudioParts, } from './userInput.js';
8
12
  export { isAbortSignal, } from '../types/index.js';
@@ -1,4 +1,5 @@
1
- import type { ModelMessage } from 'ai';
1
+ import type { ModelMessage, TranscriptionModel } from 'ai';
2
+ import { type UserInputContent } from './userInput.js';
2
3
  import type { Session } from '../types/session.js';
3
4
  import type { SessionStore } from '../session/SessionStore.js';
4
5
  import type { AgentConfig } from '../types/agentConfig.js';
@@ -9,12 +10,13 @@ import type { ResolvedSelection } from '../types/selection.js';
9
10
  export interface OpenRunOptions {
10
11
  sessionId: string;
11
12
  userId?: string;
12
- input?: string;
13
+ input?: UserInputContent;
13
14
  selection?: ResolvedSelection;
14
15
  agentId?: string;
15
16
  seedMessages?: ModelMessage[];
16
17
  historyDelta?: ModelMessage[];
17
18
  signalDelivery?: SignalDelivery;
19
+ transcriptionModel?: TranscriptionModel;
18
20
  defaultAgentId: string;
19
21
  sessionStore: SessionStore;
20
22
  }
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { transcribeAudioParts } from './userInput.js';
2
3
  import { setPendingUserInput } from './channels/inputBuffer.js';
3
4
  import { SessionRunStore } from './durable/SessionRunStore.js';
4
5
  import { recordSignalDelivery } from './durable/replay.js';
@@ -45,8 +46,14 @@ export async function openRun(agentsById, options) {
45
46
  runState.updatedAt = Date.now();
46
47
  await runStore.putRunState(runState);
47
48
  }
48
- const effectiveInput = options.selection?.id ?? options.input;
49
- if (effectiveInput) {
49
+ const rawInput = options.selection?.id ?? options.input;
50
+ const effectiveInput = rawInput === undefined
51
+ ? undefined
52
+ : await transcribeAudioParts(rawInput, options.transcriptionModel);
53
+ const hasInput = typeof effectiveInput === 'string'
54
+ ? effectiveInput.length > 0
55
+ : Array.isArray(effectiveInput) && effectiveInput.length > 0;
56
+ if (hasInput && effectiveInput !== undefined) {
50
57
  runState.updatedAt = Date.now();
51
58
  if (runState.activeFlow) {
52
59
  await runStore.putRunState(runState);
@@ -23,7 +23,7 @@
23
23
  * but extends caching across short user idle periods + cross-session
24
24
  * within an hour.
25
25
  */
26
- import type { ModelMessage } from 'ai';
26
+ import type { JSONValue, ModelMessage } from 'ai';
27
27
  export type AnthropicCacheTtl = '5m' | '1h';
28
28
  /**
29
29
  * Returns a SHALLOW-COPIED message list with cache_control markers
@@ -90,3 +90,19 @@ export interface OpenAIResponsesCompactOptions {
90
90
  * branch but ignored otherwise.
91
91
  */
92
92
  export declare function buildOpenAIResponsesProviderOptions(opts: OpenAIResponsesCompactOptions, sessionId: string): Record<string, unknown> | null;
93
+ /**
94
+ * Single wiring point for provider prompt caching, called by the driver
95
+ * `streamText` sites. Gated by the conservative detectors so non-matching
96
+ * providers are untouched:
97
+ * - Anthropic → applies `cache_control` breakpoints (caches the system+tools
98
+ * prefix + recent history; ~75% input-cost + TTFT off multi-turn turns).
99
+ * - OpenAI Responses → sets `promptCacheKey` (= sessionId, pins same-session
100
+ * turns to one cache slot) + `truncation: 'auto'` overflow safety net.
101
+ * Returns the (possibly transformed) messages + an optional `providerOptions`
102
+ * bag ready to spread into `streamText`. Default-on: both are no-ops on
103
+ * providers that ignore the fields.
104
+ */
105
+ export declare function applyPromptCache(model: unknown, sessionId: string, messages: ModelMessage[]): {
106
+ messages: ModelMessage[];
107
+ providerOptions?: Record<string, Record<string, JSONValue>>;
108
+ };
@@ -110,6 +110,30 @@ export function buildOpenAIResponsesProviderOptions(opts, sessionId) {
110
110
  }
111
111
  return Object.keys(out).length > 0 ? out : null;
112
112
  }
113
+ /**
114
+ * Single wiring point for provider prompt caching, called by the driver
115
+ * `streamText` sites. Gated by the conservative detectors so non-matching
116
+ * providers are untouched:
117
+ * - Anthropic → applies `cache_control` breakpoints (caches the system+tools
118
+ * prefix + recent history; ~75% input-cost + TTFT off multi-turn turns).
119
+ * - OpenAI Responses → sets `promptCacheKey` (= sessionId, pins same-session
120
+ * turns to one cache slot) + `truncation: 'auto'` overflow safety net.
121
+ * Returns the (possibly transformed) messages + an optional `providerOptions`
122
+ * bag ready to spread into `streamText`. Default-on: both are no-ops on
123
+ * providers that ignore the fields.
124
+ */
125
+ export function applyPromptCache(model, sessionId, messages) {
126
+ const outMessages = isAnthropicLanguageModel(model)
127
+ ? applyAnthropicCacheControl(messages)
128
+ : messages;
129
+ if (isOpenAIResponsesModel(model)) {
130
+ const openai = buildOpenAIResponsesProviderOptions({ useSessionAsPromptCacheKey: true, truncationFallback: 'auto' }, sessionId);
131
+ if (openai) {
132
+ return { messages: outMessages, providerOptions: { openai: openai } };
133
+ }
134
+ }
135
+ return { messages: outMessages };
136
+ }
113
137
  function withProviderOptions(msg, cacheControl) {
114
138
  const existing = msg.providerOptions ?? {};
115
139
  const existingAnthropic = existing.anthropic ?? {};
@@ -0,0 +1,24 @@
1
+ import { type UserContent, type TranscriptionModel } from 'ai';
2
+ /**
3
+ * User-turn content the runtime accepts: plain text, or AI SDK multimodal parts
4
+ * (text + file/image/audio). This is exactly the `content` of a user `ModelMessage`,
5
+ * so it threads into the model with no translation.
6
+ *
7
+ * Durability invariant: any `FilePart.data` flowing through the runtime must be
8
+ * JSON-serializable (a base64 string, data URL, or https URL) — never a raw
9
+ * `Buffer`/`Uint8Array`. `RunState.messages`, `session.messages`, and the pending
10
+ * input buffer are all persisted through the `SessionStore` (JSON/Redis/Postgres).
11
+ */
12
+ export type UserInputContent = UserContent;
13
+ /** Text projection of user input — for confirm-gate parsing, choice matching, and
14
+ * extraction hints. Non-text parts are dropped. A plain string returns as-is. */
15
+ export declare function userInputToText(input: UserInputContent): string;
16
+ /** Whether the input carries any non-text (file/image/audio) parts. */
17
+ export declare function hasMediaParts(input: UserInputContent): boolean;
18
+ /**
19
+ * Replace audio file parts with their transcript (a text part) using an AI SDK
20
+ * transcription model. With no model configured, audio parts pass through
21
+ * unchanged — audio-capable models (e.g. Gemini) accept them directly. Non-audio
22
+ * parts (images, documents) are always left untouched.
23
+ */
24
+ export declare function transcribeAudioParts(input: UserInputContent, transcriptionModel: TranscriptionModel | undefined): Promise<UserInputContent>;
@@ -0,0 +1,67 @@
1
+ import { experimental_transcribe as transcribe, } from 'ai';
2
+ function isFilePart(part) {
3
+ return part.type === 'file';
4
+ }
5
+ function isAudioPart(part) {
6
+ return typeof part.mediaType === 'string' && part.mediaType.startsWith('audio/');
7
+ }
8
+ /**
9
+ * Normalize a FilePart's `data` into something AI SDK `transcribe` accepts as
10
+ * `audio`. A bare string is treated by `transcribe` as base64, so http(s) URLs
11
+ * must become a `URL` (fetched via the built-in download) and `data:` URLs must
12
+ * be reduced to their base64 payload. Bytes and `URL`s pass through unchanged.
13
+ */
14
+ function audioSource(data) {
15
+ if (typeof data !== 'string')
16
+ return data;
17
+ if (data.startsWith('data:')) {
18
+ const comma = data.indexOf(',');
19
+ return comma === -1 ? data : data.slice(comma + 1);
20
+ }
21
+ if (data.startsWith('http://') || data.startsWith('https://')) {
22
+ return new URL(data);
23
+ }
24
+ return data;
25
+ }
26
+ /** Text projection of user input — for confirm-gate parsing, choice matching, and
27
+ * extraction hints. Non-text parts are dropped. A plain string returns as-is. */
28
+ export function userInputToText(input) {
29
+ if (typeof input === 'string')
30
+ return input;
31
+ return input
32
+ .filter((p) => p.type === 'text')
33
+ .map((p) => p.text)
34
+ .join(' ')
35
+ .trim();
36
+ }
37
+ /** Whether the input carries any non-text (file/image/audio) parts. */
38
+ export function hasMediaParts(input) {
39
+ return typeof input !== 'string' && input.some((p) => p.type !== 'text');
40
+ }
41
+ /**
42
+ * Replace audio file parts with their transcript (a text part) using an AI SDK
43
+ * transcription model. With no model configured, audio parts pass through
44
+ * unchanged — audio-capable models (e.g. Gemini) accept them directly. Non-audio
45
+ * parts (images, documents) are always left untouched.
46
+ */
47
+ export async function transcribeAudioParts(input, transcriptionModel) {
48
+ if (!transcriptionModel || typeof input === 'string')
49
+ return input;
50
+ if (!input.some((p) => isFilePart(p) && isAudioPart(p)))
51
+ return input;
52
+ const out = [];
53
+ for (const part of input) {
54
+ if (isFilePart(part) && isAudioPart(part)) {
55
+ const { text } = await transcribe({
56
+ model: transcriptionModel,
57
+ audio: audioSource(part.data),
58
+ });
59
+ if (text.trim())
60
+ out.push({ type: 'text', text });
61
+ }
62
+ else {
63
+ out.push(part);
64
+ }
65
+ }
66
+ return out;
67
+ }
@@ -1,10 +1,11 @@
1
1
  import type { HarnessStreamPart, TurnHandle } from '../types/stream.js';
2
2
  import type { TurnResult } from '../types/channel.js';
3
3
  import type { Session } from '../types/session.js';
4
+ import type { UserInputContent } from '../runtime/userInput.js';
4
5
  import type { RuntimeLike } from '../runtime/RuntimeLike.js';
5
6
  export interface MockRuntimeRunCall {
6
7
  sessionId?: string;
7
- input?: string;
8
+ input?: UserInputContent;
8
9
  agentId?: string;
9
10
  seedMessages?: unknown[];
10
11
  }
@@ -1,4 +1,5 @@
1
1
  import type { ToolSet } from 'ai';
2
+ import type { UserInputContent } from '../runtime/userInput.js';
2
3
  import type { RunContext } from './run-context.js';
3
4
  import type { FlowNode } from './flow.js';
4
5
  import type { AnyTool } from './effectTool.js';
@@ -44,7 +45,7 @@ export interface TurnResult {
44
45
  }
45
46
  export type UserSignal = {
46
47
  type: 'message';
47
- input: string;
48
+ input: UserInputContent;
48
49
  };
49
50
  interface ToolResultRecord {
50
51
  name: string;
@@ -112,6 +112,10 @@ export interface RunContext {
112
112
  }): Promise<unknown>;
113
113
  now(): Promise<number>;
114
114
  uuid(): Promise<string>;
115
+ /** Rebase durable effect callsites to 0. The runtime calls this at flow entry so
116
+ * a flow's durable callsites are anchored to the flow — identical on fresh entry
117
+ * (after an answering turn) and on resume (where that turn does not re-run). */
118
+ resetCallsites(): void;
115
119
  }
116
120
  export type ActionContext = Pick<RunContext, 'tool' | 'approve' | 'signal' | 'now' | 'uuid' | 'emit' | 'fs'>;
117
121
  export type ToolContext = Pick<RunContext, 'session' | 'runState' | 'tool' | 'now' | 'uuid' | 'emit' | 'fs'>;
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.7.1",
9
+ "version": "0.8.0",
10
10
  "description": "A framework for structured conversational AI agents",
11
11
  "publishConfig": {
12
12
  "access": "public"
@@ -103,7 +103,7 @@
103
103
  "typescript": "^5.3.0",
104
104
  "vitest": "^3.2.4",
105
105
  "zod": "^4.0.0",
106
- "@kuralle-agents/realtime-audio": "0.7.1"
106
+ "@kuralle-agents/realtime-audio": "0.8.0"
107
107
  },
108
108
  "dependencies": {
109
109
  "chrono-node": "^2.6.0"