@falai/agent 4.0.0-alpha.2 → 4.0.0-alpha.20
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 +37 -4
- package/dist/cjs/core/Runner.d.ts.map +1 -1
- package/dist/cjs/core/Runner.js +269 -74
- 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 +50 -19
- package/dist/cjs/core/Speak.js.map +1 -1
- package/dist/cjs/core/Understand.d.ts +4 -3
- package/dist/cjs/core/Understand.d.ts.map +1 -1
- package/dist/cjs/core/Understand.js +22 -51
- package/dist/cjs/core/Understand.js.map +1 -1
- package/dist/cjs/core/contracts.d.ts +11 -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 +15 -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 +37 -4
- package/dist/core/Runner.d.ts.map +1 -1
- package/dist/core/Runner.js +270 -75
- package/dist/core/Runner.js.map +1 -1
- package/dist/core/Speak.d.ts.map +1 -1
- package/dist/core/Speak.js +51 -20
- package/dist/core/Speak.js.map +1 -1
- package/dist/core/Understand.d.ts +4 -3
- package/dist/core/Understand.d.ts.map +1 -1
- package/dist/core/Understand.js +22 -51
- package/dist/core/Understand.js.map +1 -1
- package/dist/core/contracts.d.ts +11 -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 +15 -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 +12 -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 +8 -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 +266 -71
- package/src/core/Speak.ts +53 -19
- package/src/core/Understand.ts +17 -50
- package/src/core/contracts.ts +11 -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 +15 -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"];
|
|
@@ -137,6 +138,13 @@ function talkKind(step: { collect?: readonly string[] }): StepOutcomeKind {
|
|
|
137
138
|
return step.collect?.length ? "collect" : "prompt";
|
|
138
139
|
}
|
|
139
140
|
|
|
141
|
+
/** Everything a flow collects: its own `collect`, then each talk step's, once each. */
|
|
142
|
+
function flowFields<C, D>(flow: Flow<C, D>): string[] {
|
|
143
|
+
const fields = new Set<string>(flow.collect);
|
|
144
|
+
for (const step of flow.steps) if (isTalk(step)) for (const field of step.collect ?? []) fields.add(field);
|
|
145
|
+
return [...fields];
|
|
146
|
+
}
|
|
147
|
+
|
|
140
148
|
function kindOf<C, D>(step: Step<C, D> | undefined): StepOutcomeKind {
|
|
141
149
|
if (!step) return "if";
|
|
142
150
|
if (isTalk(step)) return talkKind(step);
|
|
@@ -171,16 +179,31 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
171
179
|
// ── Load + Ingest ─────────────────────────────────────────────────────
|
|
172
180
|
|
|
173
181
|
begin(input: TurnInput<C, D>): Turn<C, D> {
|
|
174
|
-
const now =
|
|
182
|
+
const now = this.now();
|
|
183
|
+
const turn = this.open(input, describe(input, now.toISOString()), now);
|
|
184
|
+
if (turn.what.kind === "wake" && !input.session) {
|
|
185
|
+
this.ignore(turn, "no-session", turn.what.key);
|
|
186
|
+
return turn;
|
|
187
|
+
}
|
|
188
|
+
turn.ingesting = true;
|
|
189
|
+
this.ingest(turn);
|
|
190
|
+
turn.ingesting = false;
|
|
191
|
+
return turn;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private now(): Date {
|
|
195
|
+
return (this.options.clock ?? (() => new Date()))();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private open(input: TurnBase<C, D>, what: What, now: Date): Turn<C, D> {
|
|
175
199
|
const nowIso = now.toISOString();
|
|
176
|
-
const what = describe(input, nowIso);
|
|
177
200
|
const silenced = typeof input.silenced === "string" ? input.silenced : input.silenced?.reason;
|
|
178
|
-
|
|
201
|
+
return {
|
|
179
202
|
input,
|
|
180
203
|
what,
|
|
181
204
|
kind: what.kind,
|
|
182
205
|
triggerKey: what.kind === "message" ? (what.id ?? what.at) : what.key,
|
|
183
|
-
session: input.session ?
|
|
206
|
+
session: input.session ? structuredClone(input.session) : freshSession<D>(input.sessionId),
|
|
184
207
|
original: input.session,
|
|
185
208
|
now,
|
|
186
209
|
nowIso,
|
|
@@ -188,6 +211,7 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
188
211
|
context: input.context as C,
|
|
189
212
|
silenced,
|
|
190
213
|
understandAllowed: typeof input.silenced === "object" && input.silenced.understand === true,
|
|
214
|
+
skipSilenced: typeof input.silenced === "object" && input.silenced.skip === true,
|
|
191
215
|
ignored: false,
|
|
192
216
|
messages: [],
|
|
193
217
|
schedule: [],
|
|
@@ -204,14 +228,17 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
204
228
|
speakDone: false,
|
|
205
229
|
queue: [],
|
|
206
230
|
};
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
this.
|
|
213
|
-
|
|
214
|
-
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Every wake the session waits on: each parked run's own, then each silence flow's since the assistant last spoke. */
|
|
234
|
+
pendingWakes(input: PendingWakesInput<C, D>): ScheduleEntry[] {
|
|
235
|
+
// No input arrives, so nothing reads `what`: the inert wake shape only fills the field.
|
|
236
|
+
const turn = this.open({ ...input, sessionId: input.session.id }, { kind: "wake", key: "" }, this.now());
|
|
237
|
+
// `park` always sets both; the type leaves them optional, so a hand-built blob without them has no wake to give.
|
|
238
|
+
const parked = turn.session.runs.flatMap(({ status, waiting }) =>
|
|
239
|
+
status === "waiting" && waiting?.key && waiting.until ? [{ key: waiting.key, at: new Date(waiting.until) }] : [],
|
|
240
|
+
);
|
|
241
|
+
return [...parked, ...this.silenceWakes(turn)];
|
|
215
242
|
}
|
|
216
243
|
|
|
217
244
|
private ingest(turn: Turn<C, D>): void {
|
|
@@ -285,6 +312,8 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
285
312
|
const branch = step.branches?.find((b) => "if" in b && this.holds(b.if, turn, run));
|
|
286
313
|
const target = branch ? branch.then : step.else;
|
|
287
314
|
this.outcome(turn, run, { kind: "wait", status: "ok", code: "replied", next: nextLabel(target) });
|
|
315
|
+
// The reply is this run's while it moves, so a stay it reaches answers it.
|
|
316
|
+
if (!turn.floorFromIngest) turn.floorRunId = run.id;
|
|
288
317
|
this.follow(turn, run, flow, step, target);
|
|
289
318
|
this.takeFloor(turn, run);
|
|
290
319
|
}
|
|
@@ -323,8 +352,10 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
323
352
|
/** `silence:<flowId>:<sessionId>:<lastAssistantAtMs>`, honoured only while the blob still shows that silence. */
|
|
324
353
|
private silenceWake(turn: Turn<C, D>, key: string): void {
|
|
325
354
|
const rest = key.slice("silence:".length);
|
|
326
|
-
const flowId = rest.slice(0, rest.indexOf(":"));
|
|
327
355
|
const ms = Number(rest.slice(rest.lastIndexOf(":") + 1));
|
|
356
|
+
// A flow id may hold a ":" itself, so cut at the session id rather than at the first ":".
|
|
357
|
+
const cut = rest.lastIndexOf(`:${turn.session.id}:`);
|
|
358
|
+
const flowId = rest.slice(0, cut === -1 ? rest.indexOf(":") : cut);
|
|
328
359
|
const { lastAssistantAt } = turn.session;
|
|
329
360
|
const flow = this.flows.get(flowId);
|
|
330
361
|
if (!flow) {
|
|
@@ -386,11 +417,8 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
386
417
|
return null;
|
|
387
418
|
};
|
|
388
419
|
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
|
-
}
|
|
420
|
+
const blocked = this.repeatBlocks(turn, dedupeKey, repeat);
|
|
421
|
+
if (blocked) return skip(blocked);
|
|
394
422
|
if (hop >= MAX_HOP) return skip("hop-limit");
|
|
395
423
|
const live = session.runs.find((r) => r.flowId === flow.id && r.anchor === anchor);
|
|
396
424
|
if (live) {
|
|
@@ -407,9 +435,11 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
407
435
|
}
|
|
408
436
|
session.runs.push(run);
|
|
409
437
|
turn.started.push({ runId: run.id, flowId: flow.id, anchor, dedupeKey });
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
438
|
+
const eventTrigger = opts.trigger && "event" in opts.trigger ? opts.trigger : undefined;
|
|
439
|
+
const after = eventTrigger?.after;
|
|
440
|
+
const at = this.snap(turn, new Date(turn.now.getTime() + (after ? parseDuration(after) : 0)), eventTrigger?.businessHours);
|
|
441
|
+
// With no `after`, only a closed hour parks the start: inside working hours the snap returns now.
|
|
442
|
+
if (after || at.getTime() > turn.now.getTime()) {
|
|
413
443
|
this.park(turn, run, { kind: "timer", key: `${run.id}:start:${at.getTime()}` }, at);
|
|
414
444
|
this.outcome(turn, run, { kind: "wait", status: "waiting", code: "awaiting-trigger", until: at.toISOString() });
|
|
415
445
|
}
|
|
@@ -433,10 +463,15 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
433
463
|
|
|
434
464
|
private repeatAllows(turn: Turn<C, D>, flow: Flow<C, D>, trigger: Trigger<C, D>, key: string): boolean {
|
|
435
465
|
const repeat = trigger.repeat ?? defaultRepeat(triggerKind(trigger));
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
466
|
+
return !this.repeatBlocks(turn, `${flow.id}:${this.anchorOf(turn, flow)}:${repeat === "always" ? key : ""}`, repeat);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Why the claim already on `dedupeKey` stops another start, or nothing when there is none or its cooldown has run out. */
|
|
470
|
+
private repeatBlocks(turn: Turn<C, D>, dedupeKey: string, repeat: Repeat): "already-claimed" | "cooldown" | undefined {
|
|
471
|
+
const heldAt = this.claimAt(turn, dedupeKey);
|
|
472
|
+
if (heldAt === undefined) return undefined;
|
|
473
|
+
if (typeof repeat === "string") return "already-claimed";
|
|
474
|
+
return turn.now.getTime() - Date.parse(heldAt) < parseDuration(repeat.cooldown) ? "cooldown" : undefined;
|
|
440
475
|
}
|
|
441
476
|
|
|
442
477
|
/** A run as it would be if started now, for trigger-level predicates. */
|
|
@@ -508,8 +543,7 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
508
543
|
const floorRun = this.floorRun(turn);
|
|
509
544
|
const floorFlow = floorRun && this.flows.get(floorRun.flowId);
|
|
510
545
|
const eligible = this.eligibleMessageFlows(turn);
|
|
511
|
-
|
|
512
|
-
const messageFlows = !floorRun && eligible.length === 1 ? [] : eligible;
|
|
546
|
+
const messageFlows = !floorRun && this.startsUnscored(turn, eligible) ? [] : eligible;
|
|
513
547
|
const mentionFlows = this.mentionFlows()
|
|
514
548
|
.filter(({ flow, trigger }) => trigger.mention.length > 0 && this.repeatAllows(turn, flow, trigger, turn.triggerKey))
|
|
515
549
|
.map(({ flow }) => flow);
|
|
@@ -522,16 +556,25 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
522
556
|
}
|
|
523
557
|
const fields: UnderstandRequest<C, D>["fields"] = {};
|
|
524
558
|
const data: Record<string, unknown> = turn.session.data;
|
|
559
|
+
const harvestable = (field: string): boolean => {
|
|
560
|
+
const def = this.options.fields[field];
|
|
561
|
+
return def !== undefined && !isKnown(data[field]) && extractMode(def) === "anywhere";
|
|
562
|
+
};
|
|
525
563
|
for (const flow of [...(floorFlow ? [floorFlow] : []), ...eligible]) {
|
|
526
|
-
for (const
|
|
527
|
-
if (!isTalk(step)) continue;
|
|
528
|
-
for (const field of step.collect ?? []) {
|
|
529
|
-
const def = this.options.fields[field];
|
|
530
|
-
if (def && !isKnown(data[field]) && extractMode(def) === "anywhere") fields[field] = def;
|
|
531
|
-
}
|
|
532
|
-
}
|
|
564
|
+
for (const field of flowFields(flow).filter(harvestable)) fields[field] = this.options.fields[field];
|
|
533
565
|
}
|
|
534
|
-
|
|
566
|
+
const judging = messageFlows.length > 0 || mentionFlows.length > 0 || branches.length > 0 || Object.keys(fields).length > 0;
|
|
567
|
+
// With nobody on the floor the catch-all may take this message, and an opening message often says the most. The
|
|
568
|
+
// 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.
|
|
569
|
+
// 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.
|
|
570
|
+
const fallback = floorRun ? undefined : this.messageFlows(turn, (list) => list.length === 0)[0];
|
|
571
|
+
if (fallback) {
|
|
572
|
+
const first = fallback.steps[0];
|
|
573
|
+
const free = new Set<string>(first && isTalk(first) && first.question === undefined ? first.collect : []);
|
|
574
|
+
const own = flowFields(fallback).filter(harvestable);
|
|
575
|
+
if (judging || own.some((field) => !free.has(field))) for (const field of own) fields[field] = this.options.fields[field];
|
|
576
|
+
}
|
|
577
|
+
if (!judging && !Object.keys(fields).length) return null;
|
|
535
578
|
return {
|
|
536
579
|
text: turn.what.text,
|
|
537
580
|
history: this.historyOf(turn),
|
|
@@ -562,6 +605,14 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
562
605
|
return this.messageFlows(turn, (list) => list.length > 0);
|
|
563
606
|
}
|
|
564
607
|
|
|
608
|
+
/**
|
|
609
|
+
* The lone eligible flow starts without a score only when a low score would have nowhere
|
|
610
|
+
* else to send the message: no catch-all passes and the idle speaker is silent (S9).
|
|
611
|
+
*/
|
|
612
|
+
private startsUnscored(turn: Turn<C, D>, eligible: Flow<C, D>[]): boolean {
|
|
613
|
+
return eligible.length === 1 && this.options.idle === "silent" && this.messageFlows(turn, (list) => list.length === 0).length === 0;
|
|
614
|
+
}
|
|
615
|
+
|
|
565
616
|
private messageFlows(turn: Turn<C, D>, accept: (list: string[]) => boolean): Flow<C, D>[] {
|
|
566
617
|
const out: Flow<C, D>[] = [];
|
|
567
618
|
for (const flow of this.flows.values()) {
|
|
@@ -605,7 +656,7 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
605
656
|
const current = scores[asker.flowId] ?? 0;
|
|
606
657
|
const top = best(eligible.filter((f) => f.id !== asker.flowId));
|
|
607
658
|
if (top && top.score >= current + ROUTE_STICKY && top.score >= ROUTE_MIN) route = top.flow;
|
|
608
|
-
} else if (
|
|
659
|
+
} else if (this.startsUnscored(turn, eligible)) {
|
|
609
660
|
route = eligible[0];
|
|
610
661
|
} else {
|
|
611
662
|
const top = best(eligible);
|
|
@@ -632,6 +683,9 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
632
683
|
}
|
|
633
684
|
}
|
|
634
685
|
|
|
686
|
+
// Unless routing or Ingest moved it, the message is the asker's: written down, so a chain beside it cannot take it.
|
|
687
|
+
if (asker && !route && !turn.floorFromIngest) turn.floorRunId = asker.id;
|
|
688
|
+
|
|
635
689
|
for (const [field, raw] of Object.entries(understanding?.fields ?? {})) this.writeField(turn, field, raw);
|
|
636
690
|
|
|
637
691
|
if (asker && turn.session.runs.includes(asker) && asker.status === "asking") {
|
|
@@ -642,8 +696,15 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
642
696
|
}
|
|
643
697
|
|
|
644
698
|
private fireBranch(turn: Turn<C, D>, run: Run, flow: Flow<C, D>, step: TalkOf<C, D>, understanding: Understanding | null): void {
|
|
699
|
+
// Staying, a run does not take an `if` branch back onto a path it has already run: the fact it tests is still true,
|
|
700
|
+
// so it would re-run that path on every message. A `when` branch is judged on each new message and always counts.
|
|
701
|
+
// 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`.
|
|
702
|
+
const ran = (then: Next<D>): boolean =>
|
|
703
|
+
typeof then === "string" ? then === "end" || (run.visits[then] ?? 0) > 0 : "step" in then && (run.visits[then.step] ?? 0) > 0;
|
|
645
704
|
const hit = (branch: Branch<C, D>, index: number): boolean =>
|
|
646
|
-
"if" in branch
|
|
705
|
+
"if" in branch
|
|
706
|
+
? !(run.staying && ran(branch.then)) && this.holds(branch.if, turn, run)
|
|
707
|
+
: understanding?.branches[`${run.id}/${step.id}/${index}`] === true;
|
|
647
708
|
const index = (step.branches ?? []).findIndex(hit);
|
|
648
709
|
if (index < 0) return;
|
|
649
710
|
const branch = (step.branches ?? [])[index];
|
|
@@ -673,10 +734,43 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
673
734
|
|
|
674
735
|
async advance(turn: Turn<C, D>): Promise<TalkRequest<C, D> | IdleRequest<C, D> | null> {
|
|
675
736
|
if (turn.ignored) return null;
|
|
676
|
-
this.
|
|
737
|
+
this.resume(turn);
|
|
677
738
|
turn.queue = [...turn.session.runs];
|
|
678
739
|
await this.drain(turn);
|
|
679
|
-
|
|
740
|
+
// An asker that moved on without a word hands the message to the run it suspended, and that one to the next.
|
|
741
|
+
// Each pass takes a run off the suspended stack, so the stack's size bounds the loop.
|
|
742
|
+
for (let left = turn.session.runs.length; left > 0; left--) {
|
|
743
|
+
if (turn.what.kind !== "message" || turn.silenced !== undefined || turn.talk || turn.spokeBy.size > 0) break;
|
|
744
|
+
const resumed = this.resume(turn);
|
|
745
|
+
if (!resumed) break;
|
|
746
|
+
turn.queue.push(resumed);
|
|
747
|
+
await this.drain(turn);
|
|
748
|
+
}
|
|
749
|
+
const speaker = this.speaker(turn);
|
|
750
|
+
if (speaker && !("idle" in speaker) && this.askFixed(turn, speaker)) {
|
|
751
|
+
turn.talk = undefined;
|
|
752
|
+
return null;
|
|
753
|
+
}
|
|
754
|
+
return speaker;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Send the step's fixed question when this is its first ask: every field it collects still unknown, none asked yet,
|
|
759
|
+
* and the run not staying (a staying run answers the lead). The question counts as one ask of each field. False when
|
|
760
|
+
* the AI should phrase the ask instead.
|
|
761
|
+
*/
|
|
762
|
+
private askFixed(turn: Turn<C, D>, { run, step, pending }: TalkRequest<C, D>): boolean {
|
|
763
|
+
const { question, collect = [] } = step;
|
|
764
|
+
if (question === undefined || run.staying) return false;
|
|
765
|
+
if (pending.length !== collect.length || pending.some((field) => (run.asked[field] ?? 0) > 0)) return false;
|
|
766
|
+
const key = this.stepKey(run, step);
|
|
767
|
+
turn.messages.push({ text: render(question, this.scope(turn, run)), kind: "verbatim", afterMs: turn.afterMs, key, runId: run.id, stepId: step.id });
|
|
768
|
+
turn.afterMs = 0;
|
|
769
|
+
turn.spokeBy.add(run.id);
|
|
770
|
+
turn.spoke = true;
|
|
771
|
+
for (const field of pending) run.asked[field] = (run.asked[field] ?? 0) + 1;
|
|
772
|
+
this.outcome(turn, run, { kind: "collect", status: "ok", key, code: "asked-fixed", stepId: step.id });
|
|
773
|
+
return true;
|
|
680
774
|
}
|
|
681
775
|
|
|
682
776
|
/**
|
|
@@ -700,12 +794,16 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
700
794
|
const instructions = [...(this.options.instructions ?? []), ...own.instructions].filter((ins) => !ins.if || evaluate(ins.if, ctx, conditions));
|
|
701
795
|
const all = this.options.tools ?? [];
|
|
702
796
|
const allowed = own.tools;
|
|
797
|
+
// deferTalk leaves a `deferred` outcome as the step's last line; the retry wake re-runs the step without adding one.
|
|
798
|
+
const last = "idle" in talk ? undefined : talk.run.outcomes.at(-1);
|
|
799
|
+
const retry = !("idle" in talk) && last?.status === "deferred" && last.stepId === talk.step.id;
|
|
703
800
|
return {
|
|
704
801
|
talk,
|
|
705
|
-
input: turn.what.kind === "message" ? { kind: turn.kind, text: turn.what.text } : { kind: turn.kind },
|
|
802
|
+
input: turn.what.kind === "message" ? { kind: turn.kind, text: turn.what.text } : { kind: turn.kind, ...(retry ? { retry } : {}) },
|
|
706
803
|
context: turn.context,
|
|
707
804
|
data: turn.session.data,
|
|
708
805
|
history: this.historyOf(turn),
|
|
806
|
+
said: [...turn.messages],
|
|
709
807
|
now: turn.now,
|
|
710
808
|
instructions,
|
|
711
809
|
tools: allowed ? all.filter((tool) => allowed.includes(tool.id)) : all,
|
|
@@ -746,6 +844,11 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
746
844
|
return;
|
|
747
845
|
}
|
|
748
846
|
if (!this.premiseHolds(turn, run, flow)) return;
|
|
847
|
+
// The flow was edited off 'stay' after this run finished its steps: its new `onEnd` decides now.
|
|
848
|
+
if (run.staying && flow.onEnd !== "stay") {
|
|
849
|
+
this.finishFlow(turn, run, flow);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
749
852
|
const resuming = run.status === "asking";
|
|
750
853
|
run.status = "running";
|
|
751
854
|
if (run.stepId === null) {
|
|
@@ -788,6 +891,12 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
788
891
|
return ok;
|
|
789
892
|
}
|
|
790
893
|
|
|
894
|
+
/** `silenced: { skip: true }`: the message this step would send is dropped, and the run takes `then` as if it had gone out. */
|
|
895
|
+
private skipSilenced(turn: Turn<C, D>, run: Run, flow: Flow<C, D>, step: Step<C, D>, kind: StepOutcomeKind): void {
|
|
896
|
+
this.outcome(turn, run, { kind, status: "skipped", key: this.stepKey(run, step), code: "silenced", detail: turn.silenced, next: nextLabel(step.then) });
|
|
897
|
+
this.follow(turn, run, flow, step, step.then);
|
|
898
|
+
}
|
|
899
|
+
|
|
791
900
|
private async execute(turn: Turn<C, D>, run: Run, flow: Flow<C, D>, step: Step<C, D>, resuming: boolean): Promise<void> {
|
|
792
901
|
const data: Record<string, unknown> = turn.session.data;
|
|
793
902
|
const key = this.stepKey(run, step);
|
|
@@ -797,7 +906,9 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
797
906
|
let pending: string[] = [];
|
|
798
907
|
if (step.collect?.length) {
|
|
799
908
|
pending = pendingFields(step, data, run.asked);
|
|
800
|
-
|
|
909
|
+
// A `stay` run answers even with nothing left to collect: answering is what it stays for. It only runs on a message
|
|
910
|
+
// (asking runs sit out wakes and events), and on a silenced one the check below keeps it asking.
|
|
911
|
+
if (!pending.length && !run.staying) {
|
|
801
912
|
this.reportMaxAsks(turn, run, step);
|
|
802
913
|
this.outcome(turn, run, resuming
|
|
803
914
|
? { kind, status: "ok", key, next: nextLabel(step.then) }
|
|
@@ -808,6 +919,7 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
808
919
|
}
|
|
809
920
|
if (turn.silenced !== undefined) {
|
|
810
921
|
if (resuming) run.status = "asking"; // the gate is closed; it speaks when the gate opens
|
|
922
|
+
else if (turn.skipSilenced) this.skipSilenced(turn, run, flow, step, kind);
|
|
811
923
|
else this.endRun(turn, run, "skipped", "silenced", kind, turn.silenced);
|
|
812
924
|
return;
|
|
813
925
|
}
|
|
@@ -818,13 +930,16 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
818
930
|
}
|
|
819
931
|
}
|
|
820
932
|
run.status = "asking";
|
|
933
|
+
// 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
934
|
if (!turn.speakDone) turn.talk = { run, flow, step, pending };
|
|
935
|
+
else this.askFixed(turn, { run, flow, step, pending });
|
|
822
936
|
return;
|
|
823
937
|
}
|
|
824
938
|
|
|
825
939
|
if (isSay(step)) {
|
|
826
940
|
if (turn.silenced !== undefined) {
|
|
827
|
-
this.
|
|
941
|
+
if (turn.skipSilenced) this.skipSilenced(turn, run, flow, step, "say");
|
|
942
|
+
else this.endRun(turn, run, "skipped", "silenced", "say", turn.silenced);
|
|
828
943
|
return;
|
|
829
944
|
}
|
|
830
945
|
if (step.once) {
|
|
@@ -870,6 +985,13 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
870
985
|
result = { failed: error instanceof Error ? error.message : String(error) };
|
|
871
986
|
}
|
|
872
987
|
}
|
|
988
|
+
// The handler already ran, so a defer that cannot be parsed must not throw
|
|
989
|
+
// the turn: every replay would repeat the side effect and fail again.
|
|
990
|
+
if ("defer" in result && !isDuration(String(result.defer))) {
|
|
991
|
+
result = {
|
|
992
|
+
failed: `action "${step.do}" asked to defer by "${String(result.defer)}", which is not a duration. Use a value like "2m" or "1h".`,
|
|
993
|
+
};
|
|
994
|
+
}
|
|
873
995
|
if ("ok" in result) {
|
|
874
996
|
if (result.spoke) {
|
|
875
997
|
turn.spokeBy.add(run.id);
|
|
@@ -959,18 +1081,22 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
959
1081
|
}
|
|
960
1082
|
if ("step" in target) {
|
|
961
1083
|
const data: Record<string, unknown> = turn.session.data;
|
|
962
|
-
|
|
1084
|
+
// Forgetting a field means asking for it from scratch: its ask count goes too, so a fixed question goes out again.
|
|
1085
|
+
for (const field of target.clear ?? []) {
|
|
1086
|
+
delete data[field];
|
|
1087
|
+
delete run.asked[field];
|
|
1088
|
+
}
|
|
963
1089
|
this.jump(turn, run, flow, target.step);
|
|
964
1090
|
return;
|
|
965
1091
|
}
|
|
966
1092
|
const childId = render(target.flow, this.scope(turn, run));
|
|
967
1093
|
const key = this.stepKey(run, step);
|
|
968
1094
|
this.endRun(turn, run, "flow");
|
|
969
|
-
this.chain(turn, childId, key, target.input ?? run.input, run.hop + 1);
|
|
1095
|
+
this.chain(turn, run, childId, key, target.input ?? run.input, run.hop + 1);
|
|
970
1096
|
}
|
|
971
1097
|
|
|
972
|
-
/** Start a child flow that
|
|
973
|
-
private chain(turn: Turn<C, D>, flowId: string, key: string, input: unknown, hop: number, keepData = false): void {
|
|
1098
|
+
/** Start a child flow that moves in this same phase. It inherits the floor when its parent held it, or when nobody did. */
|
|
1099
|
+
private chain(turn: Turn<C, D>, parent: Run, flowId: string, key: string, input: unknown, hop: number, keepData = false): void {
|
|
974
1100
|
const child = this.flows.get(flowId);
|
|
975
1101
|
if (!child) {
|
|
976
1102
|
turn.skipped.push({ flowId, anchor: turn.session.id, triggerKey: key, code: "flow-gone", message: OUTCOME_MESSAGES["flow-gone"] });
|
|
@@ -978,8 +1104,10 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
978
1104
|
}
|
|
979
1105
|
const run = this.startRun(turn, child, "flow", key, { payload: input, hop, keepData });
|
|
980
1106
|
if (!run) return;
|
|
981
|
-
turn.floorRunId
|
|
982
|
-
|
|
1107
|
+
if (turn.floorRunId === undefined || turn.floorRunId === parent.id) {
|
|
1108
|
+
turn.floorRunId = run.id;
|
|
1109
|
+
if (turn.ingesting) turn.floorFromIngest = true;
|
|
1110
|
+
}
|
|
983
1111
|
turn.queue.push(run);
|
|
984
1112
|
}
|
|
985
1113
|
|
|
@@ -994,15 +1122,55 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
994
1122
|
run.visits[stepId] = (run.visits[stepId] ?? 0) + 1;
|
|
995
1123
|
run.status = "running";
|
|
996
1124
|
delete run.waiting;
|
|
1125
|
+
delete run.staying;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/** `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. */
|
|
1129
|
+
private stayAt(run: Run, step: Step<C, D>): void {
|
|
1130
|
+
this.enter(run, step.id);
|
|
1131
|
+
run.staying = true;
|
|
1132
|
+
run.status = "asking";
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* 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.
|
|
1137
|
+
* 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
|
|
1138
|
+
* stays on the flow's last talk step. Upgrade: record the talk step on the run when it talks.
|
|
1139
|
+
*/
|
|
1140
|
+
private stayStep(run: Run, flow: Flow<C, D>): TalkOf<C, D> | undefined {
|
|
1141
|
+
for (let i = run.outcomes.length - 1; i >= 0; i--) {
|
|
1142
|
+
const { kind, stepId } = run.outcomes[i];
|
|
1143
|
+
const step = kind === "prompt" || kind === "collect" ? this.stepOf(flow, stepId ?? null) : undefined;
|
|
1144
|
+
if (step && isTalk(step)) return step;
|
|
1145
|
+
}
|
|
1146
|
+
return [...flow.steps].reverse().find(isTalk);
|
|
997
1147
|
}
|
|
998
1148
|
|
|
999
1149
|
private finishFlow(turn: Turn<C, D>, run: Run, flow: Flow<C, D>): void {
|
|
1000
1150
|
const onEnd = flow.onEnd ?? "end";
|
|
1001
1151
|
const last = flow.steps[flow.steps.length - 1];
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
run
|
|
1152
|
+
const stay = onEnd === "stay" ? this.stayStep(run, flow) : undefined;
|
|
1153
|
+
if (stay) {
|
|
1154
|
+
// The steps after it ran once, on the way here; staying, it only answers.
|
|
1155
|
+
this.stayAt(run, stay);
|
|
1156
|
+
// One asker at a time. A run the lead's message went to holds the conversation and suspends the other asker;
|
|
1157
|
+
// any other run waits behind that asker and resumes when it is done.
|
|
1158
|
+
const others = turn.session.runs.filter((r) => r !== run && r.status === "asking");
|
|
1159
|
+
if (others.length && !(turn.what.kind === "message" && turn.floorRunId === run.id)) {
|
|
1160
|
+
run.status = "suspended";
|
|
1161
|
+
run.suspendedAt = turn.nowIso;
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
for (const other of others) {
|
|
1165
|
+
other.status = "suspended";
|
|
1166
|
+
other.suspendedAt = turn.nowIso;
|
|
1167
|
+
if (turn.talk?.run === other) turn.talk = undefined;
|
|
1168
|
+
}
|
|
1169
|
+
// A message nothing has answered yet gets its answer now. Reached in Ingest, the run stays asking: Decide judges its
|
|
1170
|
+
// branches as the asker's, and it answers when it moves.
|
|
1171
|
+
if (!turn.ingesting && turn.what.kind === "message" && turn.silenced === undefined && !turn.speakDone && turn.spokeBy.size === 0) {
|
|
1172
|
+
run.status = "running";
|
|
1173
|
+
}
|
|
1006
1174
|
return;
|
|
1007
1175
|
}
|
|
1008
1176
|
if (onEnd === "reset" && last) {
|
|
@@ -1010,7 +1178,7 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
1010
1178
|
// 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
1179
|
const key = this.stepKey(run, last);
|
|
1012
1180
|
this.endRun(turn, run, "reset");
|
|
1013
|
-
this.chain(turn, flow.id, key, run.input, run.hop + 1, true);
|
|
1181
|
+
this.chain(turn, run, flow.id, key, run.input, run.hop + 1, true);
|
|
1014
1182
|
return;
|
|
1015
1183
|
}
|
|
1016
1184
|
this.endRun(turn, run, "end");
|
|
@@ -1033,9 +1201,20 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
1033
1201
|
turn.schedule.push({ key: waiting.key, at });
|
|
1034
1202
|
}
|
|
1035
1203
|
|
|
1204
|
+
/** `resumeSuspended`, and on a message the resumed run's `if` branches are judged before it speaks, as the asker's are in Decide. */
|
|
1205
|
+
private resume(turn: Turn<C, D>): Run | undefined {
|
|
1206
|
+
const run = this.resumeSuspended(turn);
|
|
1207
|
+
if (run && turn.what.kind === "message") {
|
|
1208
|
+
const flow = this.flows.get(run.flowId);
|
|
1209
|
+
const step = flow && this.stepOf(flow, run.stepId);
|
|
1210
|
+
if (flow && step && isTalk(step)) this.fireBranch(turn, run, flow, step, null);
|
|
1211
|
+
}
|
|
1212
|
+
return run;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1036
1215
|
/** When nobody asks, the most recently suspended run returns to asking. */
|
|
1037
|
-
private resumeSuspended(turn: Turn<C, D>):
|
|
1038
|
-
if (this.asker(turn)) return;
|
|
1216
|
+
private resumeSuspended(turn: Turn<C, D>): Run | undefined {
|
|
1217
|
+
if (this.asker(turn)) return undefined;
|
|
1039
1218
|
const suspendedAt = (r: Run): string => r.suspendedAt ?? r.startedAt;
|
|
1040
1219
|
const next = turn.session.runs
|
|
1041
1220
|
.filter((r) => r.status === "suspended")
|
|
@@ -1044,6 +1223,7 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
1044
1223
|
next.status = "asking";
|
|
1045
1224
|
delete next.suspendedAt;
|
|
1046
1225
|
}
|
|
1226
|
+
return next;
|
|
1047
1227
|
}
|
|
1048
1228
|
|
|
1049
1229
|
// ── Settle: the one applier after Speak (design §4.7) ─────────────────
|
|
@@ -1093,15 +1273,22 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
1093
1273
|
Object.assign(turn.session.data, spoken.data);
|
|
1094
1274
|
this.outcome(turn, run, { kind: talkKind(step), status: "ok", key, llmCalls: spoken.llmCalls, stepId: step.id });
|
|
1095
1275
|
if (!turn.session.runs.includes(run)) return;
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1276
|
+
const pending = step.collect?.length ? pendingFields(step, turn.session.data, run.asked) : [];
|
|
1277
|
+
for (const field of pending) run.asked[field] = (run.asked[field] ?? 0) + 1;
|
|
1278
|
+
if (run.staying) {
|
|
1279
|
+
// 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.
|
|
1280
|
+
const maxAsks = step.maxAsks ?? DEFAULT_MAX_ASKS;
|
|
1281
|
+
for (const field of pending) {
|
|
1282
|
+
if (run.asked[field] === maxAsks) this.outcome(turn, run, { kind: "collect", status: "skipped", key, code: "max-asks", detail: field });
|
|
1102
1283
|
}
|
|
1103
|
-
this.
|
|
1284
|
+
this.stayAt(run, step);
|
|
1285
|
+
return;
|
|
1104
1286
|
}
|
|
1287
|
+
if (pending.length) {
|
|
1288
|
+
run.status = "asking";
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
this.reportMaxAsks(turn, run, step);
|
|
1105
1292
|
run.status = "running";
|
|
1106
1293
|
this.follow(turn, run, flow, step, step.then);
|
|
1107
1294
|
if (turn.session.runs.includes(run) && run.status === "running") turn.queue.push(run);
|
|
@@ -1139,14 +1326,21 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
1139
1326
|
return new Date(reset !== undefined && reset > ladder ? reset : ladder);
|
|
1140
1327
|
}
|
|
1141
1328
|
|
|
1142
|
-
/** The assistant spoke
|
|
1329
|
+
/** The assistant spoke this turn: its silence wakes replace the ones its last words set. */
|
|
1143
1330
|
private armSilence(turn: Turn<C, D>): void {
|
|
1331
|
+
const previous = turn.original?.lastAssistantAt;
|
|
1332
|
+
if (turn.session.lastAssistantAt === previous) return;
|
|
1333
|
+
turn.schedule.push(...this.silenceWakes(turn, previous));
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/** The assistant spoke last: every silence flow passing `if` and `repeat` gets a wake. */
|
|
1337
|
+
private silenceWakes(turn: Turn<C, D>, previous?: string): ScheduleEntry[] {
|
|
1144
1338
|
const { session } = turn;
|
|
1145
1339
|
const { lastAssistantAt, lastUserAt } = session;
|
|
1146
|
-
if (!lastAssistantAt
|
|
1147
|
-
if (lastUserAt && Date.parse(lastUserAt) > Date.parse(lastAssistantAt)) return;
|
|
1340
|
+
if (!lastAssistantAt) return [];
|
|
1341
|
+
if (lastUserAt && Date.parse(lastUserAt) > Date.parse(lastAssistantAt)) return [];
|
|
1148
1342
|
const ms = Date.parse(lastAssistantAt);
|
|
1149
|
-
const
|
|
1343
|
+
const wakes: ScheduleEntry[] = [];
|
|
1150
1344
|
for (const flow of this.flows.values()) {
|
|
1151
1345
|
const trigger = (flow.on ?? []).find((t): t is SilenceTrigger<C, D> => "silence" in t);
|
|
1152
1346
|
if (!trigger) continue;
|
|
@@ -1154,12 +1348,13 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
1154
1348
|
if (trigger.if && !this.holds(trigger.if, turn, this.draftRun(turn, flow, "silence", key))) continue;
|
|
1155
1349
|
if (!this.repeatAllows(turn, flow, trigger, key)) continue;
|
|
1156
1350
|
const at = this.snap(turn, new Date(ms + parseDuration(trigger.silence)), trigger.businessHours);
|
|
1157
|
-
|
|
1351
|
+
wakes.push({
|
|
1158
1352
|
key: `silence:${flow.id}:${session.id}:${ms}`,
|
|
1159
1353
|
at,
|
|
1160
1354
|
...(previous ? { replaces: `silence:${flow.id}:${session.id}:${Date.parse(previous)}` } : {}),
|
|
1161
1355
|
});
|
|
1162
1356
|
}
|
|
1357
|
+
return wakes;
|
|
1163
1358
|
}
|
|
1164
1359
|
|
|
1165
1360
|
// ── Return (design §4.8) ──────────────────────────────────────────────
|