@msm-core/mini 0.9.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +364 -0
- package/README.md +54 -0
- package/dist/adapters/index.d.ts +12 -2
- package/dist/adapters/index.js +10 -0
- package/dist/adapters/memory-control.d.ts +43 -0
- package/dist/adapters/memory-control.js +56 -0
- package/dist/adapters/memory-dedup.d.ts +36 -0
- package/dist/adapters/memory-dedup.js +59 -0
- package/dist/adapters/memory-lock.d.ts +45 -0
- package/dist/adapters/memory-lock.js +89 -0
- package/dist/adapters/redis-control.d.ts +2 -1
- package/dist/adapters/redis-lock.d.ts +9 -5
- package/dist/brain/anthropic.js +29 -4
- package/dist/brain/factory.js +55 -2
- package/dist/brain/gemini.d.ts +18 -2
- package/dist/brain/gemini.js +84 -12
- package/dist/brain/ollama.js +8 -2
- package/dist/brain/openai.js +9 -2
- package/dist/brain/retry.d.ts +98 -2
- package/dist/brain/retry.js +132 -2
- package/dist/brain/streaming.d.ts +80 -5
- package/dist/brain/streaming.js +81 -13
- package/dist/core/hooks.d.ts +14 -2
- package/dist/core/hooks.js +20 -3
- package/dist/core/loop.js +189 -25
- package/dist/core/types.d.ts +395 -4
- package/dist/core/types.js +90 -0
- package/dist/definition/parser.d.ts +3 -0
- package/dist/definition/parser.js +52 -1
- package/dist/definition/skills.d.ts +74 -0
- package/dist/definition/skills.js +119 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +28 -0
- package/dist/tools/dedup.d.ts +22 -1
- package/dist/tools/dedup.js +28 -0
- package/dist/tools/executor.d.ts +49 -5
- package/dist/tools/executor.js +38 -5
- package/package.json +4 -3
package/dist/core/loop.js
CHANGED
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { randomBytes } from "crypto";
|
|
11
11
|
import { compactionAnchor, compactionBoundary, deriveMessages, } from "@msm-core/session";
|
|
12
|
+
// The declaration thread of ص٣/٢, and the only values this file takes from
|
|
13
|
+
// `types.js`: one predicate to read the hook's opt-in, one wrapper to pass it on.
|
|
14
|
+
import { acceptResets, isResetAwareHook } from "./types.js";
|
|
12
15
|
import { toWireMessage } from "../brain/tool-context.js";
|
|
13
16
|
import { resolveGuards, checkGuards, hasHardBlock, hardAction, } from "./guards.js";
|
|
14
17
|
import { runGates } from "./gates.js";
|
|
@@ -18,6 +21,7 @@ import { RedisMemory, connectRedis } from "../adapters/redis-memory.js";
|
|
|
18
21
|
import { RedisControlBus } from "../adapters/redis-control.js";
|
|
19
22
|
import { RedisDistributedLock } from "../adapters/redis-lock.js";
|
|
20
23
|
import { executeTool, toToolDefinitions } from "../tools/executor.js";
|
|
24
|
+
import { RedisToolDedup } from "../tools/dedup.js";
|
|
21
25
|
import { parseDefinition } from "../definition/parser.js";
|
|
22
26
|
function makeTaskId() {
|
|
23
27
|
return randomBytes(8).toString("hex");
|
|
@@ -305,11 +309,62 @@ function assertRequestReconstructible(args) {
|
|
|
305
309
|
throw invariantError("the tool results", turnId, stepId, expectedResults, sentResults);
|
|
306
310
|
}
|
|
307
311
|
}
|
|
312
|
+
/**
|
|
313
|
+
* The four ports Redis can back, each paired with what a reader of the error
|
|
314
|
+
* will recognise it as. In the order a turn resolves them, so an error listing
|
|
315
|
+
* several reads in the order the run would have needed them.
|
|
316
|
+
*/
|
|
317
|
+
const REDIS_BACKED_PORTS = [
|
|
318
|
+
["memory", "session memory — history, metadata, document state"],
|
|
319
|
+
["controlBus", "the control bus — kill / pause / disable-tool"],
|
|
320
|
+
["lock", "the session run-lock"],
|
|
321
|
+
["dedup", "tool-call dedup"],
|
|
322
|
+
];
|
|
323
|
+
/**
|
|
324
|
+
* `redis` is optional (ص٤) under exactly one rule: **whatever it would have
|
|
325
|
+
* backed must be injected instead.** This is where that rule is enforced, and
|
|
326
|
+
* where it is enforced is half the point.
|
|
327
|
+
*
|
|
328
|
+
* A port is resolved lazily, deep inside a turn, on a path a given event may
|
|
329
|
+
* not even take. Left to itself, a composition that dropped `redis` while
|
|
330
|
+
* forgetting `dedup` would run fine until the first tool call — possibly in
|
|
331
|
+
* production, hours after the process started — and then fail with a message
|
|
332
|
+
* about a Redis config nobody wrote. A composition mistake belongs at
|
|
333
|
+
* composition time, named by the exact field that is missing, before a single
|
|
334
|
+
* event is handled and before a single token is paid for.
|
|
335
|
+
*
|
|
336
|
+
* Absence is read from the VALUE, not from the key: `exactOptionalPropertyTypes`
|
|
337
|
+
* stops a TypeScript caller writing `redis: undefined`, but a JavaScript host
|
|
338
|
+
* spreading an options bag can hand one over, and that caller deserves this
|
|
339
|
+
* error rather than a `TypeError` from inside `getRedis()`.
|
|
340
|
+
*
|
|
341
|
+
* With `redis` present this returns immediately and nothing about any existing
|
|
342
|
+
* composition changes.
|
|
343
|
+
*/
|
|
344
|
+
function assertRedisOrPorts(config) {
|
|
345
|
+
if (config.redis !== undefined)
|
|
346
|
+
return;
|
|
347
|
+
const missing = REDIS_BACKED_PORTS.filter(([field]) => config[field] === undefined);
|
|
348
|
+
if (missing.length === 0)
|
|
349
|
+
return;
|
|
350
|
+
const named = missing
|
|
351
|
+
.map(([field, what]) => `'${field}' (${what})`)
|
|
352
|
+
.join(", ");
|
|
353
|
+
const one = missing.length === 1;
|
|
354
|
+
throw new Error(`msm-mini: AgentConfig has no 'redis', so every port Redis would have ` +
|
|
355
|
+
`backed must be injected — missing ${one ? "port" : "ports"}: ${named}. ` +
|
|
356
|
+
`Inject ${one ? "it" : "them"} (InMemorySessionStore / InMemoryControlBus / ` +
|
|
357
|
+
`InMemoryLock / InMemoryToolDedup ship from '@msm-core/mini/adapters'), ` +
|
|
358
|
+
`or supply 'redis'.`);
|
|
359
|
+
}
|
|
308
360
|
/**
|
|
309
361
|
* Create a stateful agent from config.
|
|
310
362
|
* Returns an Agent object — call .handle() on every incoming event.
|
|
311
363
|
*/
|
|
312
364
|
export function createAgent(config) {
|
|
365
|
+
// Before anything else: a redis-less config with an unbacked port is a
|
|
366
|
+
// composition error, and it is raised here rather than mid-turn (ص٤).
|
|
367
|
+
assertRedisOrPorts(config);
|
|
313
368
|
const def = typeof config.definition === "string"
|
|
314
369
|
? parseDefinition(config.definition)
|
|
315
370
|
: config.definition;
|
|
@@ -318,16 +373,24 @@ export function createAgent(config) {
|
|
|
318
373
|
// never applied — the documented limits silently no-op'd (M1). resolveGuards
|
|
319
374
|
// then rejects any NaN/garbage and clamps to sane bounds (H10).
|
|
320
375
|
const guards = resolveGuards({ ...def.limits, ...config.guards });
|
|
321
|
-
const prefix = config.redis
|
|
322
|
-
const ttl = config.redis
|
|
323
|
-
// Lazy-initialized shared Redis connection
|
|
376
|
+
const prefix = config.redis?.prefix ?? "msm";
|
|
377
|
+
const ttl = config.redis?.ttl ?? {};
|
|
378
|
+
// Lazy-initialized shared Redis connection.
|
|
379
|
+
//
|
|
380
|
+
// Reachable only when `config.redis` is present: `assertRedisOrPorts` has
|
|
381
|
+
// already refused a redis-less config that left any of the four ports
|
|
382
|
+
// unbacked, and a redis-less config with all four injected never calls a
|
|
383
|
+
// resolver's fallback arm. The `?.`s below are therefore about a config
|
|
384
|
+
// object that EXISTS but is empty — `redis: {}` keeps throwing the same
|
|
385
|
+
// message it always has, which is what makes it a usable "explode if opened"
|
|
386
|
+
// test config (ports.test.ts leans on exactly that).
|
|
324
387
|
let _redis = null;
|
|
325
388
|
async function getRedis() {
|
|
326
389
|
if (!_redis) {
|
|
327
|
-
if (config.redis
|
|
390
|
+
if (config.redis?.client) {
|
|
328
391
|
_redis = config.redis.client;
|
|
329
392
|
}
|
|
330
|
-
else if (config.redis
|
|
393
|
+
else if (config.redis?.url) {
|
|
331
394
|
_redis = (await connectRedis(config.redis.url));
|
|
332
395
|
}
|
|
333
396
|
else {
|
|
@@ -395,6 +458,49 @@ export function createAgent(config) {
|
|
|
395
458
|
...(ttl.document !== undefined ? { documentTtl: ttl.document } : {}),
|
|
396
459
|
});
|
|
397
460
|
}
|
|
461
|
+
// ── The other three ports (س٦) ─────────────────────────────
|
|
462
|
+
//
|
|
463
|
+
// Same shape as `resolveStore`, three times: an injected port wins and no
|
|
464
|
+
// Redis object is built for it — and, critically, `getRedis()` is not called
|
|
465
|
+
// on its account, so a run with all four ports injected never opens a
|
|
466
|
+
// connection at all. Omit one and the loop builds the bundled Redis adapter
|
|
467
|
+
// exactly as it did before, with the tenant-scoped prefix it computed.
|
|
468
|
+
//
|
|
469
|
+
// They take the prefix rather than the event because `kill/pause/resume` have
|
|
470
|
+
// no event — they are handed a `companyId`/`agentType` pair instead. That is
|
|
471
|
+
// also why these are one function each and not four `new RedisControlBus`
|
|
472
|
+
// calls: the four sites that used to construct a bus by hand (handleCore plus
|
|
473
|
+
// the three public commands) had already begun to differ in how they built
|
|
474
|
+
// their prefix, and two sites that do the same thing separately are two sites
|
|
475
|
+
// that will one day do it differently (س١).
|
|
476
|
+
async function resolveControlBus(tenantPrefix) {
|
|
477
|
+
const injected = config.controlBus;
|
|
478
|
+
if (injected)
|
|
479
|
+
return injected;
|
|
480
|
+
return new RedisControlBus(await getRedis(), tenantPrefix);
|
|
481
|
+
}
|
|
482
|
+
async function resolveLock(tenantPrefix) {
|
|
483
|
+
const injected = config.lock;
|
|
484
|
+
if (injected)
|
|
485
|
+
return injected;
|
|
486
|
+
return new RedisDistributedLock(await getRedis(), tenantPrefix);
|
|
487
|
+
}
|
|
488
|
+
async function resolveDedup(tenantPrefix) {
|
|
489
|
+
const injected = config.dedup;
|
|
490
|
+
if (injected)
|
|
491
|
+
return injected;
|
|
492
|
+
return new RedisToolDedup(await getRedis(), tenantPrefix);
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* The prefix the three public commands scope their bus with — the one
|
|
496
|
+
* expression that used to be copy-pasted into `kill`, `pause` and `resume`.
|
|
497
|
+
* Note it is NOT `tenantPrefixFor`: those take the two ids separately and
|
|
498
|
+
* fall back to the base prefix when either is missing, which is the behaviour
|
|
499
|
+
* being preserved, not tidied.
|
|
500
|
+
*/
|
|
501
|
+
function commandPrefix(companyId, agentType) {
|
|
502
|
+
return companyId && agentType ? `${prefix}:${companyId}:${agentType}` : prefix;
|
|
503
|
+
}
|
|
398
504
|
/**
|
|
399
505
|
* Public entry point — runs the loop, then persists final run metadata
|
|
400
506
|
* (status / iterations / cost) for ops dashboards. Metadata is best-effort:
|
|
@@ -434,11 +540,13 @@ export function createAgent(config) {
|
|
|
434
540
|
const effectiveGuards = event.guardsOverride
|
|
435
541
|
? resolveGuards({ ...guards, ...event.guardsOverride })
|
|
436
542
|
: guards;
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
const
|
|
543
|
+
// The three run-control ports, resolved where the Redis trio used to be
|
|
544
|
+
// constructed — injected if the composition provided them, Redis-backed
|
|
545
|
+
// from `config.redis` if not. Session memory arrives resolved from
|
|
546
|
+
// handle(). With all four injected, nothing below touches Redis.
|
|
547
|
+
const controlBus = await resolveControlBus(tenantPrefix);
|
|
548
|
+
const lock = await resolveLock(tenantPrefix);
|
|
549
|
+
const dedup = await resolveDedup(tenantPrefix);
|
|
442
550
|
// ── Gates (zero-LLM filters) ─────────────────────────────
|
|
443
551
|
const gated = runGates(sessionId, taskId, event.message, config.gates);
|
|
444
552
|
if (gated)
|
|
@@ -470,10 +578,24 @@ export function createAgent(config) {
|
|
|
470
578
|
* `state.iteration` is read when a chunk fires, not when this closure is
|
|
471
579
|
* made, so each chunk carries the step it actually belongs to — including
|
|
472
580
|
* the finalize call, which streams under the iteration it aborted on.
|
|
581
|
+
*
|
|
582
|
+
* **And it declares for resets only when the hook did (ص٣/٢).** ص٢ built
|
|
583
|
+
* the cure in `brain/retry.ts` and could not deliver it here, because a
|
|
584
|
+
* declared sink whose `fireChunk` had no reset channel would take the flag
|
|
585
|
+
* and drop it — showing the hook consumer the retried answer glued onto
|
|
586
|
+
* the truncated one, which is ب١/٢ over again. `fireChunk` now carries the
|
|
587
|
+
* flag, so the loop can ask the one question worth asking: did the person
|
|
588
|
+
* on the far end of `onChunk` say they would act on it? Declared, the sink
|
|
589
|
+
* is wrapped and `withRetry` clears-and-restarts; undeclared, `isResetAware`
|
|
590
|
+
* is false in `retry.ts`, the ص١ mute path runs, and not a byte of this
|
|
591
|
+
* run differs from yesterday. The default did not move — it was extended.
|
|
473
592
|
*/
|
|
474
|
-
const
|
|
475
|
-
? (chunk) => fireChunk(config.hooks, sessionId, state.iteration, chunk.text)
|
|
593
|
+
const rawChunkSink = config.hooks?.onChunk
|
|
594
|
+
? (chunk) => fireChunk(config.hooks, sessionId, state.iteration, chunk.text, chunk.reset)
|
|
476
595
|
: undefined;
|
|
596
|
+
const chunkSink = rawChunkSink !== undefined && isResetAwareHook(config.hooks?.onChunk)
|
|
597
|
+
? acceptResets(rawChunkSink)
|
|
598
|
+
: rawChunkSink;
|
|
477
599
|
const toolDefs = toToolDefinitions(config.tools);
|
|
478
600
|
const toolMap = new Map(config.tools.map((t) => [t.name, t]));
|
|
479
601
|
const toolResults = [];
|
|
@@ -640,6 +762,15 @@ export function createAgent(config) {
|
|
|
640
762
|
* numbered `stepId.c1 … stepId.cN`. The log shape was built for this from
|
|
641
763
|
* the start (س٢ §4: "a step carries as many tool calls as the model asked
|
|
642
764
|
* for") — this is the writer finally filling it.
|
|
765
|
+
*
|
|
766
|
+
* **And `model` is who answered, not who was asked** (س٦/٤). The request
|
|
767
|
+
* event carries `def.brain.model` — the configured name, which is the
|
|
768
|
+
* honest answer to "what did this agent ask for". It is NOT the honest
|
|
769
|
+
* answer to "what replied", and the two come apart the moment a host
|
|
770
|
+
* swaps the brain under a definition. So the responder travels on the
|
|
771
|
+
* payload, from the provider's own reply, and is written here beside the
|
|
772
|
+
* text it produced. A brain that does not report one writes no `model`
|
|
773
|
+
* key at all — an absent field, never an inferred one.
|
|
643
774
|
*/
|
|
644
775
|
const logResponse = async (payload, stepId) => {
|
|
645
776
|
if (!turnLog)
|
|
@@ -649,6 +780,7 @@ export function createAgent(config) {
|
|
|
649
780
|
const text = payload.generation?.response_text ?? payload.final_output?.text;
|
|
650
781
|
const calls = orch?.action === "use_tool" ? stepToolCalls(orch) : [];
|
|
651
782
|
await writer.writeStep("model_response", stepId, {
|
|
783
|
+
...(payload.model !== undefined ? { model: payload.model } : {}),
|
|
652
784
|
...(text !== undefined ? { text } : {}),
|
|
653
785
|
...(calls.length > 0
|
|
654
786
|
? {
|
|
@@ -760,6 +892,7 @@ export function createAgent(config) {
|
|
|
760
892
|
terminatedBy: hardSignal.type,
|
|
761
893
|
toolCalls: toolResults,
|
|
762
894
|
...(gated.validation ? { validation: gated.validation } : {}),
|
|
895
|
+
...thoughtsOf(finalPayload, gated.validation),
|
|
763
896
|
});
|
|
764
897
|
}
|
|
765
898
|
}
|
|
@@ -768,8 +901,15 @@ export function createAgent(config) {
|
|
|
768
901
|
}
|
|
769
902
|
}
|
|
770
903
|
// force_respond / escalate → emit last text if available
|
|
771
|
-
|
|
772
|
-
|
|
904
|
+
//
|
|
905
|
+
// Two sources, told apart on purpose (ت١/٢ item 4): text the model
|
|
906
|
+
// wrote in its last payload, or the canned line below when it wrote
|
|
907
|
+
// none. The last payload's `thoughts` rides out ONLY with the former
|
|
908
|
+
// — thinking accompanies the answer it produced, never a sentence
|
|
909
|
+
// the model did not write.
|
|
910
|
+
const lastWritten = state.lastPayload?.generation?.response_text ??
|
|
911
|
+
state.lastPayload?.final_output?.text;
|
|
912
|
+
const lastText = lastWritten ??
|
|
773
913
|
"I was unable to complete this request within the allowed limits. Please try a more specific question.";
|
|
774
914
|
const gatedLast = await gateAndLog(lastText, stepId);
|
|
775
915
|
const outcomeType = gatedLast.validation?.action === "block"
|
|
@@ -786,6 +926,9 @@ export function createAgent(config) {
|
|
|
786
926
|
terminatedBy: hardSignal.type,
|
|
787
927
|
toolCalls: toolResults,
|
|
788
928
|
...(gatedLast.validation ? { validation: gatedLast.validation } : {}),
|
|
929
|
+
...(lastWritten !== undefined
|
|
930
|
+
? thoughtsOf(state.lastPayload, gatedLast.validation)
|
|
931
|
+
: {}),
|
|
789
932
|
});
|
|
790
933
|
}
|
|
791
934
|
// ── Context build ─────────────────────────────────────
|
|
@@ -889,8 +1032,7 @@ export function createAgent(config) {
|
|
|
889
1032
|
agentName: def.name,
|
|
890
1033
|
...(event.tenantContext ? { tenantContext: event.tenantContext } : {}),
|
|
891
1034
|
}, {
|
|
892
|
-
|
|
893
|
-
redisPrefix: tenantPrefix,
|
|
1035
|
+
dedup,
|
|
894
1036
|
dedupTtlSeconds: ttl.toolDedup ?? 300,
|
|
895
1037
|
...(config.hooks?.onBeforeTool
|
|
896
1038
|
? { hooks: { onBeforeTool: config.hooks.onBeforeTool } }
|
|
@@ -949,6 +1091,9 @@ export function createAgent(config) {
|
|
|
949
1091
|
text: finalText,
|
|
950
1092
|
toolCalls: toolResults,
|
|
951
1093
|
...(validation ? { validation } : {}),
|
|
1094
|
+
// The last step's thinking rides out with its answer (ت١) — and only
|
|
1095
|
+
// here, not into the log: the model never sees it again.
|
|
1096
|
+
...thoughtsOf(payload, validation),
|
|
952
1097
|
});
|
|
953
1098
|
}
|
|
954
1099
|
}
|
|
@@ -978,21 +1123,15 @@ export function createAgent(config) {
|
|
|
978
1123
|
}
|
|
979
1124
|
}
|
|
980
1125
|
async function kill(sessionId, companyId, agentType) {
|
|
981
|
-
const
|
|
982
|
-
const p = companyId && agentType ? `${prefix}:${companyId}:${agentType}` : prefix;
|
|
983
|
-
const bus = new RedisControlBus(redis, p);
|
|
1126
|
+
const bus = await resolveControlBus(commandPrefix(companyId, agentType));
|
|
984
1127
|
await bus.kill(sessionId);
|
|
985
1128
|
}
|
|
986
1129
|
async function pause(sessionId, companyId, agentType) {
|
|
987
|
-
const
|
|
988
|
-
const p = companyId && agentType ? `${prefix}:${companyId}:${agentType}` : prefix;
|
|
989
|
-
const bus = new RedisControlBus(redis, p);
|
|
1130
|
+
const bus = await resolveControlBus(commandPrefix(companyId, agentType));
|
|
990
1131
|
await bus.pause(sessionId);
|
|
991
1132
|
}
|
|
992
1133
|
async function resume(sessionId, companyId, agentType) {
|
|
993
|
-
const
|
|
994
|
-
const p = companyId && agentType ? `${prefix}:${companyId}:${agentType}` : prefix;
|
|
995
|
-
const bus = new RedisControlBus(redis, p);
|
|
1134
|
+
const bus = await resolveControlBus(commandPrefix(companyId, agentType));
|
|
996
1135
|
await bus.resume(sessionId);
|
|
997
1136
|
}
|
|
998
1137
|
return { handle, kill, pause, resume };
|
|
@@ -1086,6 +1225,31 @@ async function runWithTimeout(brainCall, budgetMs, controller) {
|
|
|
1086
1225
|
clearTimeout(timer);
|
|
1087
1226
|
}
|
|
1088
1227
|
}
|
|
1228
|
+
/**
|
|
1229
|
+
* `{ thoughts }` off the payload the outcome's text came from, or `{}` (ت١).
|
|
1230
|
+
*
|
|
1231
|
+
* Spread into the outcome at every site that takes its `text` from a payload,
|
|
1232
|
+
* and nowhere else — the thinking travels with the answer it belongs to. A
|
|
1233
|
+
* conditional spread so an outcome without thinking has no key, not an
|
|
1234
|
+
* `undefined` one that `toEqual` would wave through (تعميم المجلس ٦).
|
|
1235
|
+
*
|
|
1236
|
+
* **A blocked answer takes its thinking with it** (ت١/٢ item 3). The output
|
|
1237
|
+
* gate validates the TEXT; it never reads `thoughts`. So when the gate says
|
|
1238
|
+
* `block` — the outcome is `suppressed` and the text is the canned line — the
|
|
1239
|
+
* reasoning that produced the blocked answer is dropped too: what the gate
|
|
1240
|
+
* would not let out as an answer does not leave as notes. On every other
|
|
1241
|
+
* verdict (`release`, `review`, or no validator, including a gate that never
|
|
1242
|
+
* ran because the text was empty) `thoughts` passes UNGATED — it is audit
|
|
1243
|
+
* material for the host, declared as such on `LoopOutcome.thoughts`, and not
|
|
1244
|
+
* a second answer for an end user.
|
|
1245
|
+
*/
|
|
1246
|
+
function thoughtsOf(payload, validation) {
|
|
1247
|
+
if (validation?.action === "block")
|
|
1248
|
+
return {};
|
|
1249
|
+
return typeof payload?.thoughts === "string" && payload.thoughts !== ""
|
|
1250
|
+
? { thoughts: payload.thoughts }
|
|
1251
|
+
: {};
|
|
1252
|
+
}
|
|
1089
1253
|
function makeOutcome(type, sessionId, taskId, state, started, extra = {}) {
|
|
1090
1254
|
return {
|
|
1091
1255
|
type,
|