@falai/agent 4.0.0-alpha.2 → 4.0.0-alpha.21
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 +5 -3
- package/dist/cjs/core/Agent.d.ts +8 -1
- package/dist/cjs/core/Agent.d.ts.map +1 -1
- package/dist/cjs/core/Agent.js +18 -0
- package/dist/cjs/core/Agent.js.map +1 -1
- package/dist/cjs/core/CompactionEngine.d.ts.map +1 -1
- package/dist/cjs/core/CompactionEngine.js +8 -3
- package/dist/cjs/core/CompactionEngine.js.map +1 -1
- package/dist/cjs/core/FlowSpec.d.ts +21 -2
- package/dist/cjs/core/FlowSpec.d.ts.map +1 -1
- package/dist/cjs/core/FlowSpec.js +324 -51
- package/dist/cjs/core/FlowSpec.js.map +1 -1
- package/dist/cjs/core/Migrate.d.ts.map +1 -1
- package/dist/cjs/core/Migrate.js +3 -1
- package/dist/cjs/core/Migrate.js.map +1 -1
- package/dist/cjs/core/Prompt.d.ts +16 -0
- package/dist/cjs/core/Prompt.d.ts.map +1 -1
- package/dist/cjs/core/Prompt.js +46 -1
- package/dist/cjs/core/Prompt.js.map +1 -1
- package/dist/cjs/core/Runner.d.ts +42 -4
- package/dist/cjs/core/Runner.d.ts.map +1 -1
- package/dist/cjs/core/Runner.js +292 -72
- package/dist/cjs/core/Runner.js.map +1 -1
- package/dist/cjs/core/Speak.d.ts.map +1 -1
- package/dist/cjs/core/Speak.js +60 -21
- package/dist/cjs/core/Speak.js.map +1 -1
- package/dist/cjs/core/Understand.d.ts +6 -3
- package/dist/cjs/core/Understand.d.ts.map +1 -1
- package/dist/cjs/core/Understand.js +34 -55
- package/dist/cjs/core/Understand.js.map +1 -1
- package/dist/cjs/core/contracts.d.ts +21 -6
- package/dist/cjs/core/contracts.d.ts.map +1 -1
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/persistence/OpenSearchStore.d.ts +2 -1
- package/dist/cjs/persistence/OpenSearchStore.d.ts.map +1 -1
- package/dist/cjs/persistence/OpenSearchStore.js +2 -2
- package/dist/cjs/persistence/OpenSearchStore.js.map +1 -1
- package/dist/cjs/persistence/RedisStore.d.ts +1 -1
- package/dist/cjs/persistence/RedisStore.d.ts.map +1 -1
- package/dist/cjs/providers/AnthropicProvider.d.ts +2 -5
- package/dist/cjs/providers/AnthropicProvider.d.ts.map +1 -1
- package/dist/cjs/providers/AnthropicProvider.js +3 -4
- package/dist/cjs/providers/AnthropicProvider.js.map +1 -1
- package/dist/cjs/providers/DeepSeekProvider.d.ts.map +1 -1
- package/dist/cjs/providers/DeepSeekProvider.js +3 -4
- package/dist/cjs/providers/DeepSeekProvider.js.map +1 -1
- package/dist/cjs/providers/FallbackAiProvider.js +1 -1
- package/dist/cjs/providers/FallbackAiProvider.js.map +1 -1
- package/dist/cjs/providers/GeminiProvider.d.ts +1 -2
- package/dist/cjs/providers/GeminiProvider.d.ts.map +1 -1
- package/dist/cjs/providers/GeminiProvider.js +3 -4
- package/dist/cjs/providers/GeminiProvider.js.map +1 -1
- package/dist/cjs/providers/GenericOpenAICompatibleProvider.js +4 -4
- package/dist/cjs/providers/GenericOpenAICompatibleProvider.js.map +1 -1
- package/dist/cjs/providers/OpenAIProvider.d.ts.map +1 -1
- package/dist/cjs/providers/OpenAIProvider.js +3 -4
- package/dist/cjs/providers/OpenAIProvider.js.map +1 -1
- package/dist/cjs/providers/OpenRouterProvider.d.ts.map +1 -1
- package/dist/cjs/providers/OpenRouterProvider.js +4 -3
- package/dist/cjs/providers/OpenRouterProvider.js.map +1 -1
- package/dist/cjs/providers/ProviderAdapter.d.ts +15 -10
- package/dist/cjs/providers/ProviderAdapter.d.ts.map +1 -1
- package/dist/cjs/providers/ProviderAdapter.js +8 -3
- package/dist/cjs/providers/ProviderAdapter.js.map +1 -1
- package/dist/cjs/providers/ZaiProvider.js +1 -1
- package/dist/cjs/providers/ZaiProvider.js.map +1 -1
- package/dist/cjs/types/agent.d.ts +19 -4
- package/dist/cjs/types/agent.d.ts.map +1 -1
- package/dist/cjs/types/ai.d.ts +4 -6
- package/dist/cjs/types/ai.d.ts.map +1 -1
- package/dist/cjs/types/compaction.d.ts +1 -0
- package/dist/cjs/types/compaction.d.ts.map +1 -1
- package/dist/cjs/types/errors.d.ts +4 -9
- package/dist/cjs/types/errors.d.ts.map +1 -1
- package/dist/cjs/types/errors.js +12 -12
- package/dist/cjs/types/errors.js.map +1 -1
- package/dist/cjs/types/flow.d.ts +16 -1
- package/dist/cjs/types/flow.d.ts.map +1 -1
- package/dist/cjs/types/history.d.ts +0 -7
- package/dist/cjs/types/history.d.ts.map +1 -1
- package/dist/cjs/types/index.d.ts +2 -2
- package/dist/cjs/types/index.d.ts.map +1 -1
- package/dist/cjs/types/session.d.ts +4 -2
- package/dist/cjs/types/session.d.ts.map +1 -1
- package/dist/cjs/utils/clock.js +1 -1
- package/dist/cjs/utils/clock.js.map +1 -1
- package/dist/cjs/utils/outcomes.d.ts +1 -0
- package/dist/cjs/utils/outcomes.d.ts.map +1 -1
- package/dist/cjs/utils/outcomes.js +1 -0
- package/dist/cjs/utils/outcomes.js.map +1 -1
- package/dist/cjs/utils/phrases.d.ts +25 -0
- package/dist/cjs/utils/phrases.d.ts.map +1 -0
- package/dist/cjs/utils/phrases.js +38 -0
- package/dist/cjs/utils/phrases.js.map +1 -0
- package/dist/cjs/utils/schema.d.ts +3 -12
- package/dist/cjs/utils/schema.d.ts.map +1 -1
- package/dist/cjs/utils/schema.js +3 -43
- package/dist/cjs/utils/schema.js.map +1 -1
- package/dist/cjs/utils/template.d.ts +8 -0
- package/dist/cjs/utils/template.d.ts.map +1 -1
- package/dist/cjs/utils/template.js +47 -3
- package/dist/cjs/utils/template.js.map +1 -1
- package/dist/core/Agent.d.ts +8 -1
- package/dist/core/Agent.d.ts.map +1 -1
- package/dist/core/Agent.js +19 -1
- package/dist/core/Agent.js.map +1 -1
- package/dist/core/CompactionEngine.d.ts.map +1 -1
- package/dist/core/CompactionEngine.js +8 -3
- package/dist/core/CompactionEngine.js.map +1 -1
- package/dist/core/FlowSpec.d.ts +21 -2
- package/dist/core/FlowSpec.d.ts.map +1 -1
- package/dist/core/FlowSpec.js +323 -52
- package/dist/core/FlowSpec.js.map +1 -1
- package/dist/core/Migrate.d.ts.map +1 -1
- package/dist/core/Migrate.js +3 -1
- package/dist/core/Migrate.js.map +1 -1
- package/dist/core/Prompt.d.ts +16 -0
- package/dist/core/Prompt.d.ts.map +1 -1
- package/dist/core/Prompt.js +44 -1
- package/dist/core/Prompt.js.map +1 -1
- package/dist/core/Runner.d.ts +42 -4
- package/dist/core/Runner.d.ts.map +1 -1
- package/dist/core/Runner.js +293 -73
- package/dist/core/Runner.js.map +1 -1
- package/dist/core/Speak.d.ts.map +1 -1
- package/dist/core/Speak.js +61 -22
- package/dist/core/Speak.js.map +1 -1
- package/dist/core/Understand.d.ts +6 -3
- package/dist/core/Understand.d.ts.map +1 -1
- package/dist/core/Understand.js +34 -55
- package/dist/core/Understand.js.map +1 -1
- package/dist/core/contracts.d.ts +21 -6
- package/dist/core/contracts.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/persistence/OpenSearchStore.d.ts +2 -1
- package/dist/persistence/OpenSearchStore.d.ts.map +1 -1
- package/dist/persistence/OpenSearchStore.js +2 -2
- package/dist/persistence/OpenSearchStore.js.map +1 -1
- package/dist/persistence/RedisStore.d.ts +1 -1
- package/dist/persistence/RedisStore.d.ts.map +1 -1
- package/dist/providers/AnthropicProvider.d.ts +2 -5
- package/dist/providers/AnthropicProvider.d.ts.map +1 -1
- package/dist/providers/AnthropicProvider.js +3 -4
- package/dist/providers/AnthropicProvider.js.map +1 -1
- package/dist/providers/DeepSeekProvider.d.ts.map +1 -1
- package/dist/providers/DeepSeekProvider.js +3 -4
- package/dist/providers/DeepSeekProvider.js.map +1 -1
- package/dist/providers/FallbackAiProvider.js +1 -1
- package/dist/providers/FallbackAiProvider.js.map +1 -1
- package/dist/providers/GeminiProvider.d.ts +1 -2
- package/dist/providers/GeminiProvider.d.ts.map +1 -1
- package/dist/providers/GeminiProvider.js +3 -4
- package/dist/providers/GeminiProvider.js.map +1 -1
- package/dist/providers/GenericOpenAICompatibleProvider.js +4 -4
- package/dist/providers/GenericOpenAICompatibleProvider.js.map +1 -1
- package/dist/providers/OpenAIProvider.d.ts.map +1 -1
- package/dist/providers/OpenAIProvider.js +3 -4
- package/dist/providers/OpenAIProvider.js.map +1 -1
- package/dist/providers/OpenRouterProvider.d.ts.map +1 -1
- package/dist/providers/OpenRouterProvider.js +4 -3
- package/dist/providers/OpenRouterProvider.js.map +1 -1
- package/dist/providers/ProviderAdapter.d.ts +15 -10
- package/dist/providers/ProviderAdapter.d.ts.map +1 -1
- package/dist/providers/ProviderAdapter.js +8 -3
- package/dist/providers/ProviderAdapter.js.map +1 -1
- package/dist/providers/ZaiProvider.js +1 -1
- package/dist/providers/ZaiProvider.js.map +1 -1
- package/dist/types/agent.d.ts +19 -4
- package/dist/types/agent.d.ts.map +1 -1
- package/dist/types/ai.d.ts +4 -6
- package/dist/types/ai.d.ts.map +1 -1
- package/dist/types/compaction.d.ts +1 -0
- package/dist/types/compaction.d.ts.map +1 -1
- package/dist/types/errors.d.ts +4 -9
- package/dist/types/errors.d.ts.map +1 -1
- package/dist/types/errors.js +12 -12
- package/dist/types/errors.js.map +1 -1
- package/dist/types/flow.d.ts +16 -1
- package/dist/types/flow.d.ts.map +1 -1
- package/dist/types/history.d.ts +0 -7
- package/dist/types/history.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/session.d.ts +4 -2
- package/dist/types/session.d.ts.map +1 -1
- package/dist/utils/clock.js +1 -1
- package/dist/utils/clock.js.map +1 -1
- package/dist/utils/outcomes.d.ts +1 -0
- package/dist/utils/outcomes.d.ts.map +1 -1
- package/dist/utils/outcomes.js +1 -0
- package/dist/utils/outcomes.js.map +1 -1
- package/dist/utils/phrases.d.ts +25 -0
- package/dist/utils/phrases.d.ts.map +1 -0
- package/dist/utils/phrases.js +35 -0
- package/dist/utils/phrases.js.map +1 -0
- package/dist/utils/schema.d.ts +3 -12
- package/dist/utils/schema.d.ts.map +1 -1
- package/dist/utils/schema.js +3 -42
- package/dist/utils/schema.js.map +1 -1
- package/dist/utils/template.d.ts +8 -0
- package/dist/utils/template.d.ts.map +1 -1
- package/dist/utils/template.js +47 -3
- package/dist/utils/template.js.map +1 -1
- package/docs/concepts/architecture.md +3 -3
- package/docs/concepts/collection.md +40 -5
- package/docs/concepts/pipeline.md +13 -10
- package/docs/concepts/runs-and-waits.md +2 -2
- package/docs/guides/actions-and-events.md +2 -2
- package/docs/guides/branching.md +4 -2
- package/docs/guides/compaction.md +2 -2
- package/docs/guides/conditions.md +1 -1
- package/docs/guides/error-handling.md +3 -1
- package/docs/guides/flow-control.md +4 -2
- package/docs/guides/persistence.md +2 -2
- package/docs/guides/testing.md +1 -1
- package/docs/guides/triggers.md +5 -5
- package/docs/migration/v3-to-v4.md +16 -11
- package/docs/reference/actions-events-conditions.md +2 -2
- package/docs/reference/agent.md +11 -7
- package/docs/reference/branches.md +1 -1
- package/docs/reference/errors.md +10 -7
- package/docs/reference/fields.md +5 -3
- package/docs/reference/flow-spec.md +36 -9
- package/docs/reference/flow.md +8 -3
- package/docs/reference/outcomes.md +3 -2
- package/docs/reference/providers.md +2 -0
- package/docs/reference/session.md +2 -0
- package/docs/reference/step.md +9 -5
- package/docs/reference/stores.md +5 -3
- package/docs/reference/trigger.md +24 -4
- package/docs/rfc/v4-one-flow.md +7 -5
- package/docs/start/01-install.md +5 -3
- package/docs/start/04-add-tools.md +1 -1
- package/docs/start/05-go-to-production.md +18 -1
- package/examples/05-branches.ts +1 -1
- package/examples/06-triggers-and-waits.ts +4 -3
- package/package.json +5 -3
- package/src/core/Agent.ts +23 -2
- package/src/core/CompactionEngine.ts +10 -3
- package/src/core/FlowSpec.ts +352 -60
- package/src/core/Migrate.ts +2 -1
- package/src/core/Prompt.ts +47 -1
- package/src/core/Runner.ts +290 -69
- package/src/core/Speak.ts +64 -21
- package/src/core/Understand.ts +35 -55
- package/src/core/contracts.ts +21 -4
- package/src/index.ts +1 -1
- package/src/persistence/OpenSearchStore.ts +3 -2
- package/src/persistence/RedisStore.ts +1 -1
- package/src/providers/AnthropicProvider.ts +4 -9
- package/src/providers/DeepSeekProvider.ts +2 -4
- package/src/providers/FallbackAiProvider.ts +1 -1
- package/src/providers/GeminiProvider.ts +3 -6
- package/src/providers/GenericOpenAICompatibleProvider.ts +4 -4
- package/src/providers/OpenAIProvider.ts +3 -4
- package/src/providers/OpenRouterProvider.ts +4 -2
- package/src/providers/ProviderAdapter.ts +18 -13
- package/src/providers/ZaiProvider.ts +1 -1
- package/src/types/agent.ts +20 -5
- package/src/types/ai.ts +4 -6
- package/src/types/compaction.ts +1 -0
- package/src/types/errors.ts +11 -12
- package/src/types/flow.ts +16 -1
- package/src/types/history.ts +0 -10
- package/src/types/index.ts +1 -1
- package/src/types/session.ts +4 -1
- package/src/utils/clock.ts +1 -1
- package/src/utils/outcomes.ts +1 -0
- package/src/utils/phrases.ts +40 -0
- package/src/utils/schema.ts +3 -48
- package/src/utils/template.ts +46 -3
- package/dist/cjs/providers/index.d.ts +0 -26
- package/dist/cjs/providers/index.d.ts.map +0 -1
- package/dist/cjs/providers/index.js +0 -30
- package/dist/cjs/providers/index.js.map +0 -1
- package/dist/cjs/utils/clone.d.ts +0 -8
- package/dist/cjs/utils/clone.d.ts.map +0 -1
- package/dist/cjs/utils/clone.js +0 -32
- package/dist/cjs/utils/clone.js.map +0 -1
- package/dist/cjs/utils/index.d.ts +0 -9
- package/dist/cjs/utils/index.d.ts.map +0 -1
- package/dist/cjs/utils/index.js +0 -29
- package/dist/cjs/utils/index.js.map +0 -1
- package/dist/providers/index.d.ts +0 -26
- package/dist/providers/index.d.ts.map +0 -1
- package/dist/providers/index.js +0 -17
- package/dist/providers/index.js.map +0 -1
- package/dist/utils/clone.d.ts +0 -8
- package/dist/utils/clone.d.ts.map +0 -1
- package/dist/utils/clone.js +0 -29
- package/dist/utils/clone.js.map +0 -1
- package/dist/utils/index.d.ts +0 -9
- package/dist/utils/index.d.ts.map +0 -1
- package/dist/utils/index.js +0 -9
- package/dist/utils/index.js.map +0 -1
- package/src/providers/index.ts +0 -38
- package/src/utils/clone.ts +0 -34
- package/src/utils/index.ts +0 -18
package/src/core/Runner.ts
CHANGED
|
@@ -8,13 +8,12 @@
|
|
|
8
8
|
* begin ─▶ understandRequest ─(Understand)─▶ decide ─▶ advance ─(Speak)─▶ settle ─▶ finish
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type { AgentOptions, EndReason, TurnInput, TurnResult } from "../types/agent.js";
|
|
11
|
+
import type { AgentOptions, EndReason, PendingWakesInput, ScheduleEntry, TurnBase, TurnInput, TurnResult } from "../types/agent.js";
|
|
12
12
|
import type { TokenUsage } from "../types/ai.js";
|
|
13
13
|
import type { ActionResult, Branch, DoStep, Duration, Flow, IfStep, Next, Pred, PredCtx, Repeat, SayStep, Step, StepBase, TalkStep, Trigger, WaitEventStep, WaitStep } from "../types/flow.js";
|
|
14
14
|
import type { History } from "../types/history.js";
|
|
15
15
|
import type { Run, Session, StepOutcome, StepOutcomeCode, StepOutcomeKind, TriggerKind } from "../types/session.js";
|
|
16
|
-
import {
|
|
17
|
-
import { parseDuration } from "../utils/duration.js";
|
|
16
|
+
import { isDuration, parseDuration } from "../utils/duration.js";
|
|
18
17
|
import { OUTCOME_MESSAGES } from "../utils/outcomes.js";
|
|
19
18
|
import { coerceField, DEFAULT_MAX_ASKS, extractMode, isKnown, pendingFields } from "../utils/schema.js";
|
|
20
19
|
import { render, renderDeep } from "../utils/template.js";
|
|
@@ -49,7 +48,7 @@ export type What =
|
|
|
49
48
|
|
|
50
49
|
/** The mutable per-turn state. `session` is a deep copy; the input is never touched. */
|
|
51
50
|
export interface Turn<C = unknown, D = unknown> {
|
|
52
|
-
readonly input:
|
|
51
|
+
readonly input: TurnBase<C, D>;
|
|
53
52
|
readonly what: What;
|
|
54
53
|
readonly kind: InputKind;
|
|
55
54
|
/** Message id, wake key, event key or start key: the trigger key of runs started this turn. */
|
|
@@ -62,6 +61,8 @@ export interface Turn<C = unknown, D = unknown> {
|
|
|
62
61
|
readonly silenced?: string;
|
|
63
62
|
/** The host gate allows the understand call even though the assistant cannot speak. */
|
|
64
63
|
readonly understandAllowed: boolean;
|
|
64
|
+
/** The host gate only stops messages: a talk or `say` step is skipped and the run goes on. */
|
|
65
|
+
readonly skipSilenced: boolean;
|
|
65
66
|
/** Set by a short-circuit: the input is a no-op and nothing is saved. */
|
|
66
67
|
ignored: boolean;
|
|
67
68
|
messages: TurnResult<D>["messages"];
|
|
@@ -86,6 +87,8 @@ export interface Turn<C = unknown, D = unknown> {
|
|
|
86
87
|
/** A short `wait` waiting to ride on the next message. */
|
|
87
88
|
afterMs: number;
|
|
88
89
|
talk?: TalkRequest<C, D>;
|
|
90
|
+
/** Understand's judgment that the lead's message asks something; absent when nothing judged it. */
|
|
91
|
+
asks?: boolean;
|
|
89
92
|
/** Speak already happened (or was skipped): a talk step reached now waits for the next message. */
|
|
90
93
|
speakDone: boolean;
|
|
91
94
|
/** Runs still to move this phase; `follow` appends children. */
|
|
@@ -137,6 +140,13 @@ function talkKind(step: { collect?: readonly string[] }): StepOutcomeKind {
|
|
|
137
140
|
return step.collect?.length ? "collect" : "prompt";
|
|
138
141
|
}
|
|
139
142
|
|
|
143
|
+
/** Everything a flow collects: its own `collect`, then each talk step's, once each. */
|
|
144
|
+
function flowFields<C, D>(flow: Flow<C, D>): string[] {
|
|
145
|
+
const fields = new Set<string>(flow.collect);
|
|
146
|
+
for (const step of flow.steps) if (isTalk(step)) for (const field of step.collect ?? []) fields.add(field);
|
|
147
|
+
return [...fields];
|
|
148
|
+
}
|
|
149
|
+
|
|
140
150
|
function kindOf<C, D>(step: Step<C, D> | undefined): StepOutcomeKind {
|
|
141
151
|
if (!step) return "if";
|
|
142
152
|
if (isTalk(step)) return talkKind(step);
|
|
@@ -171,16 +181,31 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
171
181
|
// ── Load + Ingest ─────────────────────────────────────────────────────
|
|
172
182
|
|
|
173
183
|
begin(input: TurnInput<C, D>): Turn<C, D> {
|
|
174
|
-
const now =
|
|
184
|
+
const now = this.now();
|
|
185
|
+
const turn = this.open(input, describe(input, now.toISOString()), now);
|
|
186
|
+
if (turn.what.kind === "wake" && !input.session) {
|
|
187
|
+
this.ignore(turn, "no-session", turn.what.key);
|
|
188
|
+
return turn;
|
|
189
|
+
}
|
|
190
|
+
turn.ingesting = true;
|
|
191
|
+
this.ingest(turn);
|
|
192
|
+
turn.ingesting = false;
|
|
193
|
+
return turn;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private now(): Date {
|
|
197
|
+
return (this.options.clock ?? (() => new Date()))();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private open(input: TurnBase<C, D>, what: What, now: Date): Turn<C, D> {
|
|
175
201
|
const nowIso = now.toISOString();
|
|
176
|
-
const what = describe(input, nowIso);
|
|
177
202
|
const silenced = typeof input.silenced === "string" ? input.silenced : input.silenced?.reason;
|
|
178
|
-
|
|
203
|
+
return {
|
|
179
204
|
input,
|
|
180
205
|
what,
|
|
181
206
|
kind: what.kind,
|
|
182
207
|
triggerKey: what.kind === "message" ? (what.id ?? what.at) : what.key,
|
|
183
|
-
session: input.session ?
|
|
208
|
+
session: input.session ? structuredClone(input.session) : freshSession<D>(input.sessionId),
|
|
184
209
|
original: input.session,
|
|
185
210
|
now,
|
|
186
211
|
nowIso,
|
|
@@ -188,6 +213,7 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
188
213
|
context: input.context as C,
|
|
189
214
|
silenced,
|
|
190
215
|
understandAllowed: typeof input.silenced === "object" && input.silenced.understand === true,
|
|
216
|
+
skipSilenced: typeof input.silenced === "object" && input.silenced.skip === true,
|
|
191
217
|
ignored: false,
|
|
192
218
|
messages: [],
|
|
193
219
|
schedule: [],
|
|
@@ -204,14 +230,17 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
204
230
|
speakDone: false,
|
|
205
231
|
queue: [],
|
|
206
232
|
};
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
this.
|
|
213
|
-
|
|
214
|
-
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Every wake the session waits on: each parked run's own, then each silence flow's since the assistant last spoke. */
|
|
236
|
+
pendingWakes(input: PendingWakesInput<C, D>): ScheduleEntry[] {
|
|
237
|
+
// No input arrives, so nothing reads `what`: the inert wake shape only fills the field.
|
|
238
|
+
const turn = this.open({ ...input, sessionId: input.session.id }, { kind: "wake", key: "" }, this.now());
|
|
239
|
+
// `park` always sets both; the type leaves them optional, so a hand-built blob without them has no wake to give.
|
|
240
|
+
const parked = turn.session.runs.flatMap(({ status, waiting }) =>
|
|
241
|
+
status === "waiting" && waiting?.key && waiting.until ? [{ key: waiting.key, at: new Date(waiting.until) }] : [],
|
|
242
|
+
);
|
|
243
|
+
return [...parked, ...this.silenceWakes(turn)];
|
|
215
244
|
}
|
|
216
245
|
|
|
217
246
|
private ingest(turn: Turn<C, D>): void {
|
|
@@ -285,6 +314,8 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
285
314
|
const branch = step.branches?.find((b) => "if" in b && this.holds(b.if, turn, run));
|
|
286
315
|
const target = branch ? branch.then : step.else;
|
|
287
316
|
this.outcome(turn, run, { kind: "wait", status: "ok", code: "replied", next: nextLabel(target) });
|
|
317
|
+
// The reply is this run's while it moves, so a stay it reaches answers it.
|
|
318
|
+
if (!turn.floorFromIngest) turn.floorRunId = run.id;
|
|
288
319
|
this.follow(turn, run, flow, step, target);
|
|
289
320
|
this.takeFloor(turn, run);
|
|
290
321
|
}
|
|
@@ -323,8 +354,10 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
323
354
|
/** `silence:<flowId>:<sessionId>:<lastAssistantAtMs>`, honoured only while the blob still shows that silence. */
|
|
324
355
|
private silenceWake(turn: Turn<C, D>, key: string): void {
|
|
325
356
|
const rest = key.slice("silence:".length);
|
|
326
|
-
const flowId = rest.slice(0, rest.indexOf(":"));
|
|
327
357
|
const ms = Number(rest.slice(rest.lastIndexOf(":") + 1));
|
|
358
|
+
// A flow id may hold a ":" itself, so cut at the session id rather than at the first ":".
|
|
359
|
+
const cut = rest.lastIndexOf(`:${turn.session.id}:`);
|
|
360
|
+
const flowId = rest.slice(0, cut === -1 ? rest.indexOf(":") : cut);
|
|
328
361
|
const { lastAssistantAt } = turn.session;
|
|
329
362
|
const flow = this.flows.get(flowId);
|
|
330
363
|
if (!flow) {
|
|
@@ -386,11 +419,8 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
386
419
|
return null;
|
|
387
420
|
};
|
|
388
421
|
if (opts.trigger?.if && !this.holds(opts.trigger.if, turn, run)) return null;
|
|
389
|
-
const
|
|
390
|
-
if (
|
|
391
|
-
if (typeof repeat === "string") return skip("already-claimed");
|
|
392
|
-
if (turn.now.getTime() - Date.parse(heldAt) < parseDuration(repeat.cooldown)) return skip("cooldown");
|
|
393
|
-
}
|
|
422
|
+
const blocked = this.repeatBlocks(turn, dedupeKey, repeat);
|
|
423
|
+
if (blocked) return skip(blocked);
|
|
394
424
|
if (hop >= MAX_HOP) return skip("hop-limit");
|
|
395
425
|
const live = session.runs.find((r) => r.flowId === flow.id && r.anchor === anchor);
|
|
396
426
|
if (live) {
|
|
@@ -407,9 +437,11 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
407
437
|
}
|
|
408
438
|
session.runs.push(run);
|
|
409
439
|
turn.started.push({ runId: run.id, flowId: flow.id, anchor, dedupeKey });
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
440
|
+
const eventTrigger = opts.trigger && "event" in opts.trigger ? opts.trigger : undefined;
|
|
441
|
+
const after = eventTrigger?.after;
|
|
442
|
+
const at = this.snap(turn, new Date(turn.now.getTime() + (after ? parseDuration(after) : 0)), eventTrigger?.businessHours);
|
|
443
|
+
// With no `after`, only a closed hour parks the start: inside working hours the snap returns now.
|
|
444
|
+
if (after || at.getTime() > turn.now.getTime()) {
|
|
413
445
|
this.park(turn, run, { kind: "timer", key: `${run.id}:start:${at.getTime()}` }, at);
|
|
414
446
|
this.outcome(turn, run, { kind: "wait", status: "waiting", code: "awaiting-trigger", until: at.toISOString() });
|
|
415
447
|
}
|
|
@@ -433,10 +465,15 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
433
465
|
|
|
434
466
|
private repeatAllows(turn: Turn<C, D>, flow: Flow<C, D>, trigger: Trigger<C, D>, key: string): boolean {
|
|
435
467
|
const repeat = trigger.repeat ?? defaultRepeat(triggerKind(trigger));
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
468
|
+
return !this.repeatBlocks(turn, `${flow.id}:${this.anchorOf(turn, flow)}:${repeat === "always" ? key : ""}`, repeat);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Why the claim already on `dedupeKey` stops another start, or nothing when there is none or its cooldown has run out. */
|
|
472
|
+
private repeatBlocks(turn: Turn<C, D>, dedupeKey: string, repeat: Repeat): "already-claimed" | "cooldown" | undefined {
|
|
473
|
+
const heldAt = this.claimAt(turn, dedupeKey);
|
|
474
|
+
if (heldAt === undefined) return undefined;
|
|
475
|
+
if (typeof repeat === "string") return "already-claimed";
|
|
476
|
+
return turn.now.getTime() - Date.parse(heldAt) < parseDuration(repeat.cooldown) ? "cooldown" : undefined;
|
|
440
477
|
}
|
|
441
478
|
|
|
442
479
|
/** A run as it would be if started now, for trigger-level predicates. */
|
|
@@ -508,8 +545,7 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
508
545
|
const floorRun = this.floorRun(turn);
|
|
509
546
|
const floorFlow = floorRun && this.flows.get(floorRun.flowId);
|
|
510
547
|
const eligible = this.eligibleMessageFlows(turn);
|
|
511
|
-
|
|
512
|
-
const messageFlows = !floorRun && eligible.length === 1 ? [] : eligible;
|
|
548
|
+
const messageFlows = !floorRun && this.startsUnscored(turn, eligible) ? [] : eligible;
|
|
513
549
|
const mentionFlows = this.mentionFlows()
|
|
514
550
|
.filter(({ flow, trigger }) => trigger.mention.length > 0 && this.repeatAllows(turn, flow, trigger, turn.triggerKey))
|
|
515
551
|
.map(({ flow }) => flow);
|
|
@@ -522,16 +558,25 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
522
558
|
}
|
|
523
559
|
const fields: UnderstandRequest<C, D>["fields"] = {};
|
|
524
560
|
const data: Record<string, unknown> = turn.session.data;
|
|
561
|
+
const harvestable = (field: string): boolean => {
|
|
562
|
+
const def = this.options.fields[field];
|
|
563
|
+
return def !== undefined && !isKnown(data[field]) && extractMode(def) === "anywhere";
|
|
564
|
+
};
|
|
525
565
|
for (const flow of [...(floorFlow ? [floorFlow] : []), ...eligible]) {
|
|
526
|
-
for (const
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
566
|
+
for (const field of flowFields(flow).filter(harvestable)) fields[field] = this.options.fields[field];
|
|
567
|
+
}
|
|
568
|
+
const judging = messageFlows.length > 0 || mentionFlows.length > 0 || branches.length > 0 || Object.keys(fields).length > 0;
|
|
569
|
+
// With nobody on the floor the catch-all may take this message, and an opening message often says the most. The
|
|
570
|
+
// fields its first step asks ride on that step's speak call, so alone they spend no call; a fixed question has no speak call.
|
|
571
|
+
// ponytail: "first step" is steps[0]; a catch-all that opens with a say or an if pays the call for that step's fields too.
|
|
572
|
+
const fallback = floorRun ? undefined : this.messageFlows(turn, (list) => list.length === 0)[0];
|
|
573
|
+
if (fallback) {
|
|
574
|
+
const first = fallback.steps[0];
|
|
575
|
+
const free = new Set<string>(first && isTalk(first) && first.question === undefined ? first.collect : []);
|
|
576
|
+
const own = flowFields(fallback).filter(harvestable);
|
|
577
|
+
if (judging || own.some((field) => !free.has(field))) for (const field of own) fields[field] = this.options.fields[field];
|
|
533
578
|
}
|
|
534
|
-
if (!
|
|
579
|
+
if (!judging && !Object.keys(fields).length) return null;
|
|
535
580
|
return {
|
|
536
581
|
text: turn.what.text,
|
|
537
582
|
history: this.historyOf(turn),
|
|
@@ -562,6 +607,14 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
562
607
|
return this.messageFlows(turn, (list) => list.length > 0);
|
|
563
608
|
}
|
|
564
609
|
|
|
610
|
+
/**
|
|
611
|
+
* The lone eligible flow starts without a score only when a low score would have nowhere
|
|
612
|
+
* else to send the message: no catch-all passes and the idle speaker is silent (S9).
|
|
613
|
+
*/
|
|
614
|
+
private startsUnscored(turn: Turn<C, D>, eligible: Flow<C, D>[]): boolean {
|
|
615
|
+
return eligible.length === 1 && this.options.idle === "silent" && this.messageFlows(turn, (list) => list.length === 0).length === 0;
|
|
616
|
+
}
|
|
617
|
+
|
|
565
618
|
private messageFlows(turn: Turn<C, D>, accept: (list: string[]) => boolean): Flow<C, D>[] {
|
|
566
619
|
const out: Flow<C, D>[] = [];
|
|
567
620
|
for (const flow of this.flows.values()) {
|
|
@@ -590,6 +643,7 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
590
643
|
if (turn.ignored || turn.what.kind !== "message") return;
|
|
591
644
|
turn.llmCalls += understanding?.llmCalls ?? 0;
|
|
592
645
|
turn.usage = addUsage(turn.usage, understanding?.usage);
|
|
646
|
+
if (understanding?.asks !== undefined) turn.asks = understanding.asks;
|
|
593
647
|
const key = turn.triggerKey;
|
|
594
648
|
const asker = this.asker(turn);
|
|
595
649
|
|
|
@@ -605,7 +659,7 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
605
659
|
const current = scores[asker.flowId] ?? 0;
|
|
606
660
|
const top = best(eligible.filter((f) => f.id !== asker.flowId));
|
|
607
661
|
if (top && top.score >= current + ROUTE_STICKY && top.score >= ROUTE_MIN) route = top.flow;
|
|
608
|
-
} else if (
|
|
662
|
+
} else if (this.startsUnscored(turn, eligible)) {
|
|
609
663
|
route = eligible[0];
|
|
610
664
|
} else {
|
|
611
665
|
const top = best(eligible);
|
|
@@ -632,6 +686,9 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
632
686
|
}
|
|
633
687
|
}
|
|
634
688
|
|
|
689
|
+
// Unless routing or Ingest moved it, the message is the asker's: written down, so a chain beside it cannot take it.
|
|
690
|
+
if (asker && !route && !turn.floorFromIngest) turn.floorRunId = asker.id;
|
|
691
|
+
|
|
635
692
|
for (const [field, raw] of Object.entries(understanding?.fields ?? {})) this.writeField(turn, field, raw);
|
|
636
693
|
|
|
637
694
|
if (asker && turn.session.runs.includes(asker) && asker.status === "asking") {
|
|
@@ -642,8 +699,15 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
642
699
|
}
|
|
643
700
|
|
|
644
701
|
private fireBranch(turn: Turn<C, D>, run: Run, flow: Flow<C, D>, step: TalkOf<C, D>, understanding: Understanding | null): void {
|
|
702
|
+
// Staying, a run does not take an `if` branch back onto a path it has already run: the fact it tests is still true,
|
|
703
|
+
// so it would re-run that path on every message. A `when` branch is judged on each new message and always counts.
|
|
704
|
+
// ponytail: "already run" is the target's visit count, so an `if` restart (`{ step, clear }` to a visited step) never fires while staying; write it as a `when`.
|
|
705
|
+
const ran = (then: Next<D>): boolean =>
|
|
706
|
+
typeof then === "string" ? then === "end" || (run.visits[then] ?? 0) > 0 : "step" in then && (run.visits[then.step] ?? 0) > 0;
|
|
645
707
|
const hit = (branch: Branch<C, D>, index: number): boolean =>
|
|
646
|
-
"if" in branch
|
|
708
|
+
"if" in branch
|
|
709
|
+
? !(run.staying && ran(branch.then)) && this.holds(branch.if, turn, run)
|
|
710
|
+
: understanding?.branches[`${run.id}/${step.id}/${index}`] === true;
|
|
647
711
|
const index = (step.branches ?? []).findIndex(hit);
|
|
648
712
|
if (index < 0) return;
|
|
649
713
|
const branch = (step.branches ?? [])[index];
|
|
@@ -673,10 +737,60 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
673
737
|
|
|
674
738
|
async advance(turn: Turn<C, D>): Promise<TalkRequest<C, D> | IdleRequest<C, D> | null> {
|
|
675
739
|
if (turn.ignored) return null;
|
|
676
|
-
this.
|
|
740
|
+
this.resume(turn);
|
|
677
741
|
turn.queue = [...turn.session.runs];
|
|
678
742
|
await this.drain(turn);
|
|
679
|
-
|
|
743
|
+
// An asker that moved on without a word hands the message to the run it suspended, and that one to the next.
|
|
744
|
+
// Each pass takes a run off the suspended stack, so the stack's size bounds the loop.
|
|
745
|
+
for (let left = turn.session.runs.length; left > 0; left--) {
|
|
746
|
+
if (turn.what.kind !== "message" || turn.silenced !== undefined || turn.talk || turn.spokeBy.size > 0) break;
|
|
747
|
+
const resumed = this.resume(turn);
|
|
748
|
+
if (!resumed) break;
|
|
749
|
+
turn.queue.push(resumed);
|
|
750
|
+
await this.drain(turn);
|
|
751
|
+
}
|
|
752
|
+
const speaker = this.speaker(turn);
|
|
753
|
+
if (!speaker || "idle" in speaker || !this.fixedDue(speaker)) return speaker;
|
|
754
|
+
// A question the lead asked gets its answer first: the step speaks, and its fixed question follows in settle.
|
|
755
|
+
// Unjudged counts as asked, and so does the retry of a failed answer: a question sent over the lead's own drops it.
|
|
756
|
+
const answerFirst = turn.what.kind === "message" ? turn.asks !== false : this.retrying(speaker);
|
|
757
|
+
if (answerFirst && speaker.step.question !== undefined) {
|
|
758
|
+
speaker.fixedAfter = render(speaker.step.question, this.scope(turn, speaker.run));
|
|
759
|
+
return speaker;
|
|
760
|
+
}
|
|
761
|
+
this.askFixed(turn, speaker);
|
|
762
|
+
turn.talk = undefined;
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/** The step failed at the provider and runs again on its retry wake: deferTalk left `deferred` as its last line. */
|
|
767
|
+
private retrying({ run, step }: TalkRequest<C, D>): boolean {
|
|
768
|
+
const last = run.outcomes.at(-1);
|
|
769
|
+
return last?.status === "deferred" && last.stepId === step.id;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* The step's fixed question is due: this is its first ask, with every field it collects still unknown and none asked
|
|
774
|
+
* yet, and the run is not staying (a staying run answers the lead). Otherwise the AI phrases the ask.
|
|
775
|
+
*/
|
|
776
|
+
private fixedDue({ run, step, pending }: TalkRequest<C, D>): boolean {
|
|
777
|
+
const { question, collect = [] } = step;
|
|
778
|
+
if (question === undefined || run.staying) return false;
|
|
779
|
+
return pending.length === collect.length && pending.every((field) => (run.asked[field] ?? 0) === 0);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** Send the step's fixed question when it is due. It counts as one ask of each field. */
|
|
783
|
+
private askFixed(turn: Turn<C, D>, talk: TalkRequest<C, D>): boolean {
|
|
784
|
+
const { run, step, pending } = talk;
|
|
785
|
+
if (!this.fixedDue(talk) || step.question === undefined) return false;
|
|
786
|
+
const key = this.stepKey(run, step);
|
|
787
|
+
turn.messages.push({ text: render(step.question, this.scope(turn, run)), kind: "verbatim", afterMs: turn.afterMs, key, runId: run.id, stepId: step.id });
|
|
788
|
+
turn.afterMs = 0;
|
|
789
|
+
turn.spokeBy.add(run.id);
|
|
790
|
+
turn.spoke = true;
|
|
791
|
+
for (const field of pending) run.asked[field] = (run.asked[field] ?? 0) + 1;
|
|
792
|
+
this.outcome(turn, run, { kind: "collect", status: "ok", key, code: "asked-fixed", stepId: step.id });
|
|
793
|
+
return true;
|
|
680
794
|
}
|
|
681
795
|
|
|
682
796
|
/**
|
|
@@ -700,12 +814,14 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
700
814
|
const instructions = [...(this.options.instructions ?? []), ...own.instructions].filter((ins) => !ins.if || evaluate(ins.if, ctx, conditions));
|
|
701
815
|
const all = this.options.tools ?? [];
|
|
702
816
|
const allowed = own.tools;
|
|
817
|
+
const retry = !("idle" in talk) && this.retrying(talk);
|
|
703
818
|
return {
|
|
704
819
|
talk,
|
|
705
|
-
input: turn.what.kind === "message" ? { kind: turn.kind, text: turn.what.text } : { kind: turn.kind },
|
|
820
|
+
input: turn.what.kind === "message" ? { kind: turn.kind, text: turn.what.text } : { kind: turn.kind, ...(retry ? { retry } : {}) },
|
|
706
821
|
context: turn.context,
|
|
707
822
|
data: turn.session.data,
|
|
708
823
|
history: this.historyOf(turn),
|
|
824
|
+
said: [...turn.messages],
|
|
709
825
|
now: turn.now,
|
|
710
826
|
instructions,
|
|
711
827
|
tools: allowed ? all.filter((tool) => allowed.includes(tool.id)) : all,
|
|
@@ -746,6 +862,11 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
746
862
|
return;
|
|
747
863
|
}
|
|
748
864
|
if (!this.premiseHolds(turn, run, flow)) return;
|
|
865
|
+
// The flow was edited off 'stay' after this run finished its steps: its new `onEnd` decides now.
|
|
866
|
+
if (run.staying && flow.onEnd !== "stay") {
|
|
867
|
+
this.finishFlow(turn, run, flow);
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
749
870
|
const resuming = run.status === "asking";
|
|
750
871
|
run.status = "running";
|
|
751
872
|
if (run.stepId === null) {
|
|
@@ -788,6 +909,12 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
788
909
|
return ok;
|
|
789
910
|
}
|
|
790
911
|
|
|
912
|
+
/** `silenced: { skip: true }`: the message this step would send is dropped, and the run takes `then` as if it had gone out. */
|
|
913
|
+
private skipSilenced(turn: Turn<C, D>, run: Run, flow: Flow<C, D>, step: Step<C, D>, kind: StepOutcomeKind): void {
|
|
914
|
+
this.outcome(turn, run, { kind, status: "skipped", key: this.stepKey(run, step), code: "silenced", detail: turn.silenced, next: nextLabel(step.then) });
|
|
915
|
+
this.follow(turn, run, flow, step, step.then);
|
|
916
|
+
}
|
|
917
|
+
|
|
791
918
|
private async execute(turn: Turn<C, D>, run: Run, flow: Flow<C, D>, step: Step<C, D>, resuming: boolean): Promise<void> {
|
|
792
919
|
const data: Record<string, unknown> = turn.session.data;
|
|
793
920
|
const key = this.stepKey(run, step);
|
|
@@ -797,7 +924,9 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
797
924
|
let pending: string[] = [];
|
|
798
925
|
if (step.collect?.length) {
|
|
799
926
|
pending = pendingFields(step, data, run.asked);
|
|
800
|
-
|
|
927
|
+
// A `stay` run answers even with nothing left to collect: answering is what it stays for. It only runs on a message
|
|
928
|
+
// (asking runs sit out wakes and events), and on a silenced one the check below keeps it asking.
|
|
929
|
+
if (!pending.length && !run.staying) {
|
|
801
930
|
this.reportMaxAsks(turn, run, step);
|
|
802
931
|
this.outcome(turn, run, resuming
|
|
803
932
|
? { kind, status: "ok", key, next: nextLabel(step.then) }
|
|
@@ -808,6 +937,7 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
808
937
|
}
|
|
809
938
|
if (turn.silenced !== undefined) {
|
|
810
939
|
if (resuming) run.status = "asking"; // the gate is closed; it speaks when the gate opens
|
|
940
|
+
else if (turn.skipSilenced) this.skipSilenced(turn, run, flow, step, kind);
|
|
811
941
|
else this.endRun(turn, run, "skipped", "silenced", kind, turn.silenced);
|
|
812
942
|
return;
|
|
813
943
|
}
|
|
@@ -818,13 +948,16 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
818
948
|
}
|
|
819
949
|
}
|
|
820
950
|
run.status = "asking";
|
|
951
|
+
// Reached after Speak (settle's drain), a talk step waits for the next message; a fixed question costs no call, so it goes out now, as a say would.
|
|
821
952
|
if (!turn.speakDone) turn.talk = { run, flow, step, pending };
|
|
953
|
+
else this.askFixed(turn, { run, flow, step, pending });
|
|
822
954
|
return;
|
|
823
955
|
}
|
|
824
956
|
|
|
825
957
|
if (isSay(step)) {
|
|
826
958
|
if (turn.silenced !== undefined) {
|
|
827
|
-
this.
|
|
959
|
+
if (turn.skipSilenced) this.skipSilenced(turn, run, flow, step, "say");
|
|
960
|
+
else this.endRun(turn, run, "skipped", "silenced", "say", turn.silenced);
|
|
828
961
|
return;
|
|
829
962
|
}
|
|
830
963
|
if (step.once) {
|
|
@@ -870,6 +1003,13 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
870
1003
|
result = { failed: error instanceof Error ? error.message : String(error) };
|
|
871
1004
|
}
|
|
872
1005
|
}
|
|
1006
|
+
// The handler already ran, so a defer that cannot be parsed must not throw
|
|
1007
|
+
// the turn: every replay would repeat the side effect and fail again.
|
|
1008
|
+
if ("defer" in result && !isDuration(String(result.defer))) {
|
|
1009
|
+
result = {
|
|
1010
|
+
failed: `action "${step.do}" asked to defer by "${String(result.defer)}", which is not a duration. Use a value like "2m" or "1h".`,
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
873
1013
|
if ("ok" in result) {
|
|
874
1014
|
if (result.spoke) {
|
|
875
1015
|
turn.spokeBy.add(run.id);
|
|
@@ -959,18 +1099,22 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
959
1099
|
}
|
|
960
1100
|
if ("step" in target) {
|
|
961
1101
|
const data: Record<string, unknown> = turn.session.data;
|
|
962
|
-
|
|
1102
|
+
// Forgetting a field means asking for it from scratch: its ask count goes too, so a fixed question goes out again.
|
|
1103
|
+
for (const field of target.clear ?? []) {
|
|
1104
|
+
delete data[field];
|
|
1105
|
+
delete run.asked[field];
|
|
1106
|
+
}
|
|
963
1107
|
this.jump(turn, run, flow, target.step);
|
|
964
1108
|
return;
|
|
965
1109
|
}
|
|
966
1110
|
const childId = render(target.flow, this.scope(turn, run));
|
|
967
1111
|
const key = this.stepKey(run, step);
|
|
968
1112
|
this.endRun(turn, run, "flow");
|
|
969
|
-
this.chain(turn, childId, key, target.input ?? run.input, run.hop + 1);
|
|
1113
|
+
this.chain(turn, run, childId, key, target.input ?? run.input, run.hop + 1);
|
|
970
1114
|
}
|
|
971
1115
|
|
|
972
|
-
/** Start a child flow that
|
|
973
|
-
private chain(turn: Turn<C, D>, flowId: string, key: string, input: unknown, hop: number, keepData = false): void {
|
|
1116
|
+
/** Start a child flow that moves in this same phase. It inherits the floor when its parent held it, or when nobody did. */
|
|
1117
|
+
private chain(turn: Turn<C, D>, parent: Run, flowId: string, key: string, input: unknown, hop: number, keepData = false): void {
|
|
974
1118
|
const child = this.flows.get(flowId);
|
|
975
1119
|
if (!child) {
|
|
976
1120
|
turn.skipped.push({ flowId, anchor: turn.session.id, triggerKey: key, code: "flow-gone", message: OUTCOME_MESSAGES["flow-gone"] });
|
|
@@ -978,8 +1122,10 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
978
1122
|
}
|
|
979
1123
|
const run = this.startRun(turn, child, "flow", key, { payload: input, hop, keepData });
|
|
980
1124
|
if (!run) return;
|
|
981
|
-
turn.floorRunId
|
|
982
|
-
|
|
1125
|
+
if (turn.floorRunId === undefined || turn.floorRunId === parent.id) {
|
|
1126
|
+
turn.floorRunId = run.id;
|
|
1127
|
+
if (turn.ingesting) turn.floorFromIngest = true;
|
|
1128
|
+
}
|
|
983
1129
|
turn.queue.push(run);
|
|
984
1130
|
}
|
|
985
1131
|
|
|
@@ -994,15 +1140,55 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
994
1140
|
run.visits[stepId] = (run.visits[stepId] ?? 0) + 1;
|
|
995
1141
|
run.status = "running";
|
|
996
1142
|
delete run.waiting;
|
|
1143
|
+
delete run.staying;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/** `onEnd: 'stay'`: the run sits on its last talk step and answers every message from there, each answer a new visit and so a new key. */
|
|
1147
|
+
private stayAt(run: Run, step: Step<C, D>): void {
|
|
1148
|
+
this.enter(run, step.id);
|
|
1149
|
+
run.staying = true;
|
|
1150
|
+
run.status = "asking";
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* Where `stay` answers from: the talk step this run last took, so a branched flow stays on its own path. The flow's last talk step when the log names none.
|
|
1155
|
+
* ponytail: read back from `run.outcomes`, which keeps 50 lines; a tail of 50+ lines after the talk step (a long polling loop) loses it and the run
|
|
1156
|
+
* stays on the flow's last talk step. Upgrade: record the talk step on the run when it talks.
|
|
1157
|
+
*/
|
|
1158
|
+
private stayStep(run: Run, flow: Flow<C, D>): TalkOf<C, D> | undefined {
|
|
1159
|
+
for (let i = run.outcomes.length - 1; i >= 0; i--) {
|
|
1160
|
+
const { kind, stepId } = run.outcomes[i];
|
|
1161
|
+
const step = kind === "prompt" || kind === "collect" ? this.stepOf(flow, stepId ?? null) : undefined;
|
|
1162
|
+
if (step && isTalk(step)) return step;
|
|
1163
|
+
}
|
|
1164
|
+
return [...flow.steps].reverse().find(isTalk);
|
|
997
1165
|
}
|
|
998
1166
|
|
|
999
1167
|
private finishFlow(turn: Turn<C, D>, run: Run, flow: Flow<C, D>): void {
|
|
1000
1168
|
const onEnd = flow.onEnd ?? "end";
|
|
1001
1169
|
const last = flow.steps[flow.steps.length - 1];
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
run
|
|
1170
|
+
const stay = onEnd === "stay" ? this.stayStep(run, flow) : undefined;
|
|
1171
|
+
if (stay) {
|
|
1172
|
+
// The steps after it ran once, on the way here; staying, it only answers.
|
|
1173
|
+
this.stayAt(run, stay);
|
|
1174
|
+
// One asker at a time. A run the lead's message went to holds the conversation and suspends the other asker;
|
|
1175
|
+
// any other run waits behind that asker and resumes when it is done.
|
|
1176
|
+
const others = turn.session.runs.filter((r) => r !== run && r.status === "asking");
|
|
1177
|
+
if (others.length && !(turn.what.kind === "message" && turn.floorRunId === run.id)) {
|
|
1178
|
+
run.status = "suspended";
|
|
1179
|
+
run.suspendedAt = turn.nowIso;
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
for (const other of others) {
|
|
1183
|
+
other.status = "suspended";
|
|
1184
|
+
other.suspendedAt = turn.nowIso;
|
|
1185
|
+
if (turn.talk?.run === other) turn.talk = undefined;
|
|
1186
|
+
}
|
|
1187
|
+
// A message nothing has answered yet gets its answer now. Reached in Ingest, the run stays asking: Decide judges its
|
|
1188
|
+
// branches as the asker's, and it answers when it moves.
|
|
1189
|
+
if (!turn.ingesting && turn.what.kind === "message" && turn.silenced === undefined && !turn.speakDone && turn.spokeBy.size === 0) {
|
|
1190
|
+
run.status = "running";
|
|
1191
|
+
}
|
|
1006
1192
|
return;
|
|
1007
1193
|
}
|
|
1008
1194
|
if (onEnd === "reset" && last) {
|
|
@@ -1010,7 +1196,7 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
1010
1196
|
// It is a chain into itself, so it costs a hop: a code-only flow that resets forever stops at the hop cap instead of spinning.
|
|
1011
1197
|
const key = this.stepKey(run, last);
|
|
1012
1198
|
this.endRun(turn, run, "reset");
|
|
1013
|
-
this.chain(turn, flow.id, key, run.input, run.hop + 1, true);
|
|
1199
|
+
this.chain(turn, run, flow.id, key, run.input, run.hop + 1, true);
|
|
1014
1200
|
return;
|
|
1015
1201
|
}
|
|
1016
1202
|
this.endRun(turn, run, "end");
|
|
@@ -1033,9 +1219,20 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
1033
1219
|
turn.schedule.push({ key: waiting.key, at });
|
|
1034
1220
|
}
|
|
1035
1221
|
|
|
1222
|
+
/** `resumeSuspended`, and on a message the resumed run's `if` branches are judged before it speaks, as the asker's are in Decide. */
|
|
1223
|
+
private resume(turn: Turn<C, D>): Run | undefined {
|
|
1224
|
+
const run = this.resumeSuspended(turn);
|
|
1225
|
+
if (run && turn.what.kind === "message") {
|
|
1226
|
+
const flow = this.flows.get(run.flowId);
|
|
1227
|
+
const step = flow && this.stepOf(flow, run.stepId);
|
|
1228
|
+
if (flow && step && isTalk(step)) this.fireBranch(turn, run, flow, step, null);
|
|
1229
|
+
}
|
|
1230
|
+
return run;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1036
1233
|
/** When nobody asks, the most recently suspended run returns to asking. */
|
|
1037
|
-
private resumeSuspended(turn: Turn<C, D>):
|
|
1038
|
-
if (this.asker(turn)) return;
|
|
1234
|
+
private resumeSuspended(turn: Turn<C, D>): Run | undefined {
|
|
1235
|
+
if (this.asker(turn)) return undefined;
|
|
1039
1236
|
const suspendedAt = (r: Run): string => r.suspendedAt ?? r.startedAt;
|
|
1040
1237
|
const next = turn.session.runs
|
|
1041
1238
|
.filter((r) => r.status === "suspended")
|
|
@@ -1044,6 +1241,7 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
1044
1241
|
next.status = "asking";
|
|
1045
1242
|
delete next.suspendedAt;
|
|
1046
1243
|
}
|
|
1244
|
+
return next;
|
|
1047
1245
|
}
|
|
1048
1246
|
|
|
1049
1247
|
// ── Settle: the one applier after Speak (design §4.7) ─────────────────
|
|
@@ -1093,15 +1291,30 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
1093
1291
|
Object.assign(turn.session.data, spoken.data);
|
|
1094
1292
|
this.outcome(turn, run, { kind: talkKind(step), status: "ok", key, llmCalls: spoken.llmCalls, stepId: step.id });
|
|
1095
1293
|
if (!turn.session.runs.includes(run)) return;
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1294
|
+
const pending = step.collect?.length ? pendingFields(step, turn.session.data, run.asked) : [];
|
|
1295
|
+
if (talk.fixedAfter !== undefined) {
|
|
1296
|
+
// The reply only answered the lead; the step's own question goes out after it and counts the ask.
|
|
1297
|
+
if (this.askFixed(turn, { run, flow, step, pending })) {
|
|
1100
1298
|
run.status = "asking";
|
|
1101
1299
|
return;
|
|
1102
1300
|
}
|
|
1103
|
-
|
|
1301
|
+
} else {
|
|
1302
|
+
for (const field of pending) run.asked[field] = (run.asked[field] ?? 0) + 1;
|
|
1303
|
+
}
|
|
1304
|
+
if (run.staying) {
|
|
1305
|
+
// Each answer is a new visit, so a new key, even while a field is still pending; that field's max-asks is reported once, when it gets there.
|
|
1306
|
+
const maxAsks = step.maxAsks ?? DEFAULT_MAX_ASKS;
|
|
1307
|
+
for (const field of pending) {
|
|
1308
|
+
if (run.asked[field] === maxAsks) this.outcome(turn, run, { kind: "collect", status: "skipped", key, code: "max-asks", detail: field });
|
|
1309
|
+
}
|
|
1310
|
+
this.stayAt(run, step);
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
if (pending.length) {
|
|
1314
|
+
run.status = "asking";
|
|
1315
|
+
return;
|
|
1104
1316
|
}
|
|
1317
|
+
this.reportMaxAsks(turn, run, step);
|
|
1105
1318
|
run.status = "running";
|
|
1106
1319
|
this.follow(turn, run, flow, step, step.then);
|
|
1107
1320
|
if (turn.session.runs.includes(run) && run.status === "running") turn.queue.push(run);
|
|
@@ -1139,14 +1352,21 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
1139
1352
|
return new Date(reset !== undefined && reset > ladder ? reset : ladder);
|
|
1140
1353
|
}
|
|
1141
1354
|
|
|
1142
|
-
/** The assistant spoke
|
|
1355
|
+
/** The assistant spoke this turn: its silence wakes replace the ones its last words set. */
|
|
1143
1356
|
private armSilence(turn: Turn<C, D>): void {
|
|
1357
|
+
const previous = turn.original?.lastAssistantAt;
|
|
1358
|
+
if (turn.session.lastAssistantAt === previous) return;
|
|
1359
|
+
turn.schedule.push(...this.silenceWakes(turn, previous));
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/** The assistant spoke last: every silence flow passing `if` and `repeat` gets a wake. */
|
|
1363
|
+
private silenceWakes(turn: Turn<C, D>, previous?: string): ScheduleEntry[] {
|
|
1144
1364
|
const { session } = turn;
|
|
1145
1365
|
const { lastAssistantAt, lastUserAt } = session;
|
|
1146
|
-
if (!lastAssistantAt
|
|
1147
|
-
if (lastUserAt && Date.parse(lastUserAt) > Date.parse(lastAssistantAt)) return;
|
|
1366
|
+
if (!lastAssistantAt) return [];
|
|
1367
|
+
if (lastUserAt && Date.parse(lastUserAt) > Date.parse(lastAssistantAt)) return [];
|
|
1148
1368
|
const ms = Date.parse(lastAssistantAt);
|
|
1149
|
-
const
|
|
1369
|
+
const wakes: ScheduleEntry[] = [];
|
|
1150
1370
|
for (const flow of this.flows.values()) {
|
|
1151
1371
|
const trigger = (flow.on ?? []).find((t): t is SilenceTrigger<C, D> => "silence" in t);
|
|
1152
1372
|
if (!trigger) continue;
|
|
@@ -1154,12 +1374,13 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
1154
1374
|
if (trigger.if && !this.holds(trigger.if, turn, this.draftRun(turn, flow, "silence", key))) continue;
|
|
1155
1375
|
if (!this.repeatAllows(turn, flow, trigger, key)) continue;
|
|
1156
1376
|
const at = this.snap(turn, new Date(ms + parseDuration(trigger.silence)), trigger.businessHours);
|
|
1157
|
-
|
|
1377
|
+
wakes.push({
|
|
1158
1378
|
key: `silence:${flow.id}:${session.id}:${ms}`,
|
|
1159
1379
|
at,
|
|
1160
1380
|
...(previous ? { replaces: `silence:${flow.id}:${session.id}:${Date.parse(previous)}` } : {}),
|
|
1161
1381
|
});
|
|
1162
1382
|
}
|
|
1383
|
+
return wakes;
|
|
1163
1384
|
}
|
|
1164
1385
|
|
|
1165
1386
|
// ── Return (design §4.8) ──────────────────────────────────────────────
|