@inflexa-ai/harness 0.45.0 → 0.45.1
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/dist/agents/conversation-agent.d.ts +10 -0
- package/dist/agents/conversation-agent.js +19 -1
- package/dist/agents/sandbox/shared.d.ts +23 -2
- package/dist/agents/sandbox/shared.js +51 -18
- package/dist/agents/sandbox/translational-safety-agent.js +26 -0
- package/dist/agents/sandbox/types.d.ts +24 -1
- package/dist/agents/sandbox-catalog.d.ts +14 -0
- package/dist/agents/sandbox-catalog.js +19 -0
- package/dist/app/approve-plan.d.ts +5 -2
- package/dist/app/approve-plan.js +1 -1
- package/dist/app/chat-turn.js +6 -3
- package/dist/config/environment-stores.d.ts +4 -2
- package/dist/contracts/chat-events.d.ts +12 -2
- package/dist/contracts/chat-events.js +1 -1
- package/dist/contracts/chat-frame.js +6 -2
- package/dist/contracts/index.d.ts +1 -1
- package/dist/contracts/schemas/chat-events.d.ts +25 -0
- package/dist/contracts/schemas/chat-events.js +7 -0
- package/dist/contracts/schemas/chat-parts.d.ts +39 -0
- package/dist/contracts/schemas/chat-parts.js +17 -0
- package/dist/contracts/schemas/index.d.ts +1 -1
- package/dist/contracts/schemas/index.js +1 -1
- package/dist/execution/dbos-workflow-purger.d.ts +2 -0
- package/dist/execution/dbos-workflow-purger.js +21 -14
- package/dist/execution/run-canceler.d.ts +33 -11
- package/dist/execution/run-canceler.js +129 -27
- package/dist/execution/run-end-record.d.ts +30 -0
- package/dist/execution/run-end-record.js +27 -0
- package/dist/index.d.ts +9 -5
- package/dist/index.js +11 -1
- package/dist/loop/call-guard.d.ts +50 -0
- package/dist/loop/call-guard.js +90 -0
- package/dist/loop/compaction.d.ts +5 -0
- package/dist/loop/compaction.js +17 -0
- package/dist/loop/run-agent.d.ts +9 -0
- package/dist/loop/run-agent.js +15 -1
- package/dist/loop/run-to-terminal.js +4 -1
- package/dist/loop/types.d.ts +6 -0
- package/dist/memory/conversation-display-recorder.d.ts +0 -2
- package/dist/memory/conversation-display-recorder.js +28 -3
- package/dist/memory/thread-history.d.ts +34 -7
- package/dist/memory/thread-history.js +151 -42
- package/dist/prompts/briefing.d.ts +57 -3
- package/dist/prompts/briefing.js +129 -2
- package/dist/prompts/planner.js +13 -2
- package/dist/provenance/seam.d.ts +23 -15
- package/dist/provenance/seam.js +7 -5
- package/dist/providers/ai-sdk.js +87 -7
- package/dist/providers/types.d.ts +6 -0
- package/dist/reference-data/catalog.js +141 -2
- package/dist/report-model/authoring-grammar.d.ts +26 -26
- package/dist/sandbox/image-packages.d.ts +26 -4
- package/dist/sandbox/image-packages.js +18 -4
- package/dist/schemas/data-profile-schemas.d.ts +12 -12
- package/dist/schemas/plan-schemas.d.ts +57 -3
- package/dist/schemas/plan-schemas.js +1 -1
- package/dist/schemas/workflow-state.d.ts +113 -0
- package/dist/schemas/workflow-state.js +54 -0
- package/dist/state/artifacts.d.ts +17 -1
- package/dist/state/artifacts.js +21 -3
- package/dist/state/index.d.ts +2 -2
- package/dist/state/index.js +1 -1
- package/dist/state/runs.d.ts +43 -9
- package/dist/state/runs.js +85 -14
- package/dist/state/step-executions.d.ts +9 -0
- package/dist/state/step-executions.js +22 -0
- package/dist/tools/ad-hoc-router.d.ts +2 -0
- package/dist/tools/ad-hoc-router.js +18 -2
- package/dist/tools/capability-data.d.ts +36 -0
- package/dist/tools/capability-data.js +73 -0
- package/dist/tools/execute-analysis.d.ts +37 -9
- package/dist/tools/execute-analysis.js +131 -21
- package/dist/tools/knowledge/check.d.ts +60 -0
- package/dist/tools/knowledge/check.js +92 -0
- package/dist/tools/knowledge/client.d.ts +382 -0
- package/dist/tools/knowledge/client.js +302 -0
- package/dist/tools/knowledge/environment.d.ts +78 -0
- package/dist/tools/knowledge/environment.js +105 -0
- package/dist/tools/knowledge/index.d.ts +49 -0
- package/dist/tools/knowledge/index.js +70 -0
- package/dist/tools/knowledge/local-slots.d.ts +62 -0
- package/dist/tools/knowledge/local-slots.js +177 -0
- package/dist/tools/knowledge/pin.d.ts +14 -0
- package/dist/tools/knowledge/pin.js +17 -0
- package/dist/tools/knowledge/recommend.d.ts +92 -0
- package/dist/tools/knowledge/recommend.js +98 -0
- package/dist/tools/knowledge/situation.d.ts +143 -0
- package/dist/tools/knowledge/situation.js +164 -0
- package/dist/tools/knowledge/skeleton.d.ts +69 -0
- package/dist/tools/knowledge/skeleton.js +202 -0
- package/dist/tools/knowledge/template.d.ts +137 -0
- package/dist/tools/knowledge/template.js +267 -0
- package/dist/tools/research/generate-plan.d.ts +23 -0
- package/dist/tools/research/generate-plan.js +203 -12
- package/dist/tools/research/guarded-address.d.ts +20 -0
- package/dist/tools/research/guarded-address.js +148 -0
- package/dist/tools/research/searxng-engine.d.ts +40 -0
- package/dist/tools/research/searxng-engine.js +120 -0
- package/dist/tools/research/web-fetch.d.ts +124 -0
- package/dist/tools/research/web-fetch.js +627 -0
- package/dist/tools/research/web-search.d.ts +75 -0
- package/dist/tools/research/web-search.js +112 -0
- package/dist/tools/sandbox/list-available-packages.d.ts +7 -4
- package/dist/tools/sandbox/list-available-packages.js +12 -4
- package/dist/tools/sandbox/list-available-refs.d.ts +21 -0
- package/dist/tools/sandbox/list-available-refs.js +60 -3
- package/dist/tools/sandbox/report-blocker.js +5 -1
- package/dist/tools/terminal-text.d.ts +11 -0
- package/dist/tools/terminal-text.js +19 -0
- package/dist/tools/workspace/decision-record.d.ts +81 -0
- package/dist/tools/workspace/decision-record.js +117 -0
- package/dist/tools/workspace/edit-file.d.ts +3 -1
- package/dist/tools/workspace/edit-file.js +20 -2
- package/dist/tools/workspace/execute-command.d.ts +17 -0
- package/dist/tools/workspace/execute-command.js +28 -1
- package/dist/tools/workspace/index.d.ts +1 -0
- package/dist/tools/workspace/index.js +1 -0
- package/dist/tools/workspace/mutator.d.ts +1 -1
- package/dist/workflows/execute-analysis.d.ts +58 -18
- package/dist/workflows/execute-analysis.js +273 -58
- package/dist/workflows/sandbox-step.d.ts +12 -0
- package/package.json +5 -1
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The call guard: a bound on repeated tool calls inside one agent run.
|
|
3
|
+
*
|
|
4
|
+
* A model that treats a tool answer as a state to poll calls the same tool
|
|
5
|
+
* with the same input again and again, or drifts through one query after
|
|
6
|
+
* another, until the iteration cap or the wall clock ends the run. A frontier
|
|
7
|
+
* model reads an "unavailable" answer once and continues; a smaller model
|
|
8
|
+
* does not. The guard makes that difference a host decision: the third call
|
|
9
|
+
* with an input the run already sent, the call past the budget of one tool,
|
|
10
|
+
* or the call past the budget of the whole run, answers a tool error that
|
|
11
|
+
* tells the model to continue with what it has. The service behind the tool never sees the refused call.
|
|
12
|
+
*
|
|
13
|
+
* The guard wraps a list of tools for ONE run. Build it where the tools of
|
|
14
|
+
* the run are built, never at construction of the host, thus the counters
|
|
15
|
+
* start at zero for every run. A terminal tool (one that ends the run) must
|
|
16
|
+
* not be wrapped: a refusal there would strand the run.
|
|
17
|
+
*/
|
|
18
|
+
import type { Tool } from "../tools/define-tool.js";
|
|
19
|
+
export interface CallGuardPolicy {
|
|
20
|
+
/** The calls with one identical input that a run may make to one tool. The next one is refused. */
|
|
21
|
+
readonly identicalLimit: number;
|
|
22
|
+
/** The calls, with any input, that a run may make to one tool. The next one is refused. */
|
|
23
|
+
readonly perToolLimit: number;
|
|
24
|
+
/** The calls, with any input, that a run may make across every guarded tool. The next one is refused by every tool. */
|
|
25
|
+
readonly totalLimit: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Two identical calls let a model confirm an answer once. Twelve calls of one
|
|
29
|
+
* tool is above the largest count a frontier planner made in the Phase 0
|
|
30
|
+
* campaign (six and a half calls per plan across ALL tools) and far below the
|
|
31
|
+
* two hundred that a looping planner reached. Forty calls across every tool
|
|
32
|
+
* bounds the round over the tools that stays under each per-tool budget: in
|
|
33
|
+
* the 32-task campaign every submitted Sonnet plan but two needed fewer, and
|
|
34
|
+
* the one plan that ran to the wall clock made 62.
|
|
35
|
+
*/
|
|
36
|
+
export declare const DEFAULT_CALL_GUARD: CallGuardPolicy;
|
|
37
|
+
/** A stable key for one input: the JSON text with the object keys sorted at every depth. */
|
|
38
|
+
export declare function canonicalInputKey(input: unknown): string;
|
|
39
|
+
export interface GuardedCallRefusal {
|
|
40
|
+
readonly tool: string;
|
|
41
|
+
readonly kind: "identical" | "budget" | "total";
|
|
42
|
+
readonly calls: number;
|
|
43
|
+
}
|
|
44
|
+
export interface CallGuardOptions {
|
|
45
|
+
readonly policy?: CallGuardPolicy;
|
|
46
|
+
/** Called on each refusal, for the log of the host. */
|
|
47
|
+
readonly onRefusal?: (refusal: GuardedCallRefusal) => void;
|
|
48
|
+
}
|
|
49
|
+
/** Wrap each tool so its `execute` obeys the policy for the life of the returned list. */
|
|
50
|
+
export declare function guardRepeatedCalls(tools: readonly Tool[], options?: CallGuardOptions): Tool[];
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The call guard: a bound on repeated tool calls inside one agent run.
|
|
3
|
+
*
|
|
4
|
+
* A model that treats a tool answer as a state to poll calls the same tool
|
|
5
|
+
* with the same input again and again, or drifts through one query after
|
|
6
|
+
* another, until the iteration cap or the wall clock ends the run. A frontier
|
|
7
|
+
* model reads an "unavailable" answer once and continues; a smaller model
|
|
8
|
+
* does not. The guard makes that difference a host decision: the third call
|
|
9
|
+
* with an input the run already sent, the call past the budget of one tool,
|
|
10
|
+
* or the call past the budget of the whole run, answers a tool error that
|
|
11
|
+
* tells the model to continue with what it has. The service behind the tool never sees the refused call.
|
|
12
|
+
*
|
|
13
|
+
* The guard wraps a list of tools for ONE run. Build it where the tools of
|
|
14
|
+
* the run are built, never at construction of the host, thus the counters
|
|
15
|
+
* start at zero for every run. A terminal tool (one that ends the run) must
|
|
16
|
+
* not be wrapped: a refusal there would strand the run.
|
|
17
|
+
*/
|
|
18
|
+
import { err } from "neverthrow";
|
|
19
|
+
/**
|
|
20
|
+
* Two identical calls let a model confirm an answer once. Twelve calls of one
|
|
21
|
+
* tool is above the largest count a frontier planner made in the Phase 0
|
|
22
|
+
* campaign (six and a half calls per plan across ALL tools) and far below the
|
|
23
|
+
* two hundred that a looping planner reached. Forty calls across every tool
|
|
24
|
+
* bounds the round over the tools that stays under each per-tool budget: in
|
|
25
|
+
* the 32-task campaign every submitted Sonnet plan but two needed fewer, and
|
|
26
|
+
* the one plan that ran to the wall clock made 62.
|
|
27
|
+
*/
|
|
28
|
+
export const DEFAULT_CALL_GUARD = { identicalLimit: 2, perToolLimit: 12, totalLimit: 40 };
|
|
29
|
+
/** A stable key for one input: the JSON text with the object keys sorted at every depth. */
|
|
30
|
+
export function canonicalInputKey(input) {
|
|
31
|
+
return JSON.stringify(sortKeys(input));
|
|
32
|
+
}
|
|
33
|
+
function sortKeys(value) {
|
|
34
|
+
if (Array.isArray(value))
|
|
35
|
+
return value.map(sortKeys);
|
|
36
|
+
if (value !== null && typeof value === "object") {
|
|
37
|
+
const out = {};
|
|
38
|
+
for (const key of Object.keys(value).sort())
|
|
39
|
+
out[key] = sortKeys(value[key]);
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
/** Wrap each tool so its `execute` obeys the policy for the life of the returned list. */
|
|
45
|
+
export function guardRepeatedCalls(tools, options = {}) {
|
|
46
|
+
const policy = options.policy ?? DEFAULT_CALL_GUARD;
|
|
47
|
+
let total = 0;
|
|
48
|
+
return tools.map((tool) => {
|
|
49
|
+
let calls = 0;
|
|
50
|
+
const byInput = new Map();
|
|
51
|
+
const guarded = {
|
|
52
|
+
...tool,
|
|
53
|
+
...(tool.describeCall ? { describeCall: tool.describeCall.bind(tool) } : {}),
|
|
54
|
+
...(tool.describeResult ? { describeResult: tool.describeResult.bind(tool) } : {}),
|
|
55
|
+
async execute(input, ctx) {
|
|
56
|
+
calls += 1;
|
|
57
|
+
const key = canonicalInputKey(input);
|
|
58
|
+
const identical = (byInput.get(key) ?? 0) + 1;
|
|
59
|
+
byInput.set(key, identical);
|
|
60
|
+
if (identical > policy.identicalLimit) {
|
|
61
|
+
options.onRefusal?.({ tool: tool.id, kind: "identical", calls: identical });
|
|
62
|
+
return err({
|
|
63
|
+
error: `This run already called ${tool.id} with this exact input ${identical - 1} times, and the answer does not change within a run. ` +
|
|
64
|
+
"Use the answer you have and continue. If the answer was that a resource is absent, plan without it or ask for it; do not search again.",
|
|
65
|
+
retryable: false,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
total += 1;
|
|
69
|
+
if (total > policy.totalLimit) {
|
|
70
|
+
options.onRefusal?.({ tool: tool.id, kind: "total", calls: total });
|
|
71
|
+
return err({
|
|
72
|
+
error: `This run made ${policy.totalLimit} calls across its search tools, which is the search budget of one run. ` +
|
|
73
|
+
"Submit the plan you have now. If a required resource is absent, ask for it; do not search again.",
|
|
74
|
+
retryable: false,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
if (calls > policy.perToolLimit) {
|
|
78
|
+
options.onRefusal?.({ tool: tool.id, kind: "budget", calls });
|
|
79
|
+
return err({
|
|
80
|
+
error: `This run made ${policy.perToolLimit} calls of ${tool.id}, which is the budget of one run for one tool. ` +
|
|
81
|
+
"Continue with what you have. If a required resource is absent, ask for it; do not search again.",
|
|
82
|
+
retryable: false,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return tool.execute(input, ctx);
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
return guarded;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
@@ -10,6 +10,11 @@ export interface CompactionRules {
|
|
|
10
10
|
/** The budget before the user message of a turn joins the conversation. Absent: no compaction before the user message. */
|
|
11
11
|
readonly turnStartBudget?: number;
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* The rules of an agent for a model with `contextWindow`. The budget is the smaller of the declared budget and 80% of
|
|
15
|
+
* the window. The turn-start budget keeps its ratio to the budget. An absent window keeps the declared rules.
|
|
16
|
+
*/
|
|
17
|
+
export declare function rulesForContextWindow(rules: CompactionRules, contextWindow: number | undefined): CompactionRules;
|
|
13
18
|
/** How the loop compacts its conversation when the last request passed a budget. */
|
|
14
19
|
export interface CompactionPolicy extends CompactionRules {
|
|
15
20
|
/** The provider of the conversation, with no text stream to the surface: the summary is no reply. */
|
package/dist/loop/compaction.js
CHANGED
|
@@ -1,2 +1,19 @@
|
|
|
1
1
|
/** The cap of requests of one compaction exchange: up to 3 rounds of memory edits, and then the summary. */
|
|
2
2
|
export const COMPACTION_MAX_REQUESTS = 4;
|
|
3
|
+
/** The share of a context window that a request can fill before the loop compacts. */
|
|
4
|
+
const CONTEXT_WINDOW_SHARE = 0.8;
|
|
5
|
+
/**
|
|
6
|
+
* The rules of an agent for a model with `contextWindow`. The budget is the smaller of the declared budget and 80% of
|
|
7
|
+
* the window. The turn-start budget keeps its ratio to the budget. An absent window keeps the declared rules.
|
|
8
|
+
*/
|
|
9
|
+
export function rulesForContextWindow(rules, contextWindow) {
|
|
10
|
+
if (contextWindow === undefined)
|
|
11
|
+
return rules;
|
|
12
|
+
const budget = Math.min(rules.budget, Math.floor(contextWindow * CONTEXT_WINDOW_SHARE));
|
|
13
|
+
if (budget === rules.budget)
|
|
14
|
+
return rules;
|
|
15
|
+
return {
|
|
16
|
+
budget,
|
|
17
|
+
...(rules.turnStartBudget === undefined ? {} : { turnStartBudget: Math.floor((rules.turnStartBudget * budget) / rules.budget) }),
|
|
18
|
+
};
|
|
19
|
+
}
|
package/dist/loop/run-agent.d.ts
CHANGED
|
@@ -91,6 +91,15 @@ export interface RunAgentOptions {
|
|
|
91
91
|
* prompt cache and a signed thinking block bind to.
|
|
92
92
|
*/
|
|
93
93
|
readonly toolChoice?: ChatRequest["toolChoice"];
|
|
94
|
+
/**
|
|
95
|
+
* An early cap. Read at the top of each iteration; when it answers true the
|
|
96
|
+
* loop leaves the tool phase as if `maxIterations` were reached and takes the
|
|
97
|
+
* same tool-less wrap-up path, thus a terminal wrapper salvages the run the
|
|
98
|
+
* same way. A host uses it to end a run that a policy has judged to be a
|
|
99
|
+
* loop, for example one the call guard refused too many times, without
|
|
100
|
+
* waiting for the iteration cap or the wall clock.
|
|
101
|
+
*/
|
|
102
|
+
readonly stopWhen?: () => boolean;
|
|
94
103
|
/**
|
|
95
104
|
* Optional outcome predicate for loop-driving agents whose result is
|
|
96
105
|
* recorded by a tool into closure state. Checked immediately after each
|
package/dist/loop/run-agent.js
CHANGED
|
@@ -153,6 +153,7 @@ export function openLoop(agent, initial, session, opts, metricAgentId) {
|
|
|
153
153
|
});
|
|
154
154
|
let iterations = 0;
|
|
155
155
|
let truncationRecoveries = 0;
|
|
156
|
+
let stoppedEarly = false;
|
|
156
157
|
// Counted rather than listed: the finish record stays one bounded line for a run of
|
|
157
158
|
// any length, and "50 iterations, 49 tool calls, 47 of them errored" already separates
|
|
158
159
|
// a productive long run from a loop stuck retrying one failing call. The per-iteration
|
|
@@ -372,9 +373,11 @@ export function openLoop(agent, initial, session, opts, metricAgentId) {
|
|
|
372
373
|
const records = await active.recordsAfter(conversationView([...messages, markerOf(0)], viewOptions).messages);
|
|
373
374
|
const durationMs = Math.round(performance.now() - startedAt);
|
|
374
375
|
messages.push(markerOf(durationMs), ...records);
|
|
375
|
-
await giveRound();
|
|
376
376
|
settled = true;
|
|
377
|
+
// The `done` part goes out before the marker round: a chat turn gives the rounds after a marker a new
|
|
378
|
+
// assistant message, and the `running` and `done` parts of one compaction must stream into the same message.
|
|
377
379
|
await emitPart({ id, status: "done", tokensBefore, durationMs });
|
|
380
|
+
await giveRound();
|
|
378
381
|
log.info("conversation compacted", { compactionId: id, trigger, tokensBefore, durationMs });
|
|
379
382
|
summarizedLast = true;
|
|
380
383
|
return undefined;
|
|
@@ -420,6 +423,13 @@ export function openLoop(agent, initial, session, opts, metricAgentId) {
|
|
|
420
423
|
if (segment.requestText !== undefined)
|
|
421
424
|
messages.push(syntheticUserMessage(segment.requestText));
|
|
422
425
|
for (let k = 0; k < segment.maxRequests; k++) {
|
|
426
|
+
// The early cap ends a segment before the cap as if the cap were reached, thus a run takes its
|
|
427
|
+
// tool-less wrap-up. The segment that closes a capped run is that wrap-up, and it runs to its end.
|
|
428
|
+
if (segment.closesCappedRun !== true && opts.stopWhen?.()) {
|
|
429
|
+
stoppedEarly = true;
|
|
430
|
+
log.warn("run stopped early by the host policy, taking the wrap-up path", { iterations });
|
|
431
|
+
return "capped";
|
|
432
|
+
}
|
|
423
433
|
const index = segment.firstIndex + k;
|
|
424
434
|
// The last request of a segment that closes a capped run ends the run,
|
|
425
435
|
// whatever its reply, thus its `iteration` event is the final one.
|
|
@@ -512,6 +522,8 @@ export function openLoop(agent, initial, session, opts, metricAgentId) {
|
|
|
512
522
|
// abort (user cut the turn) from a plain `max_iterations` cap-out.
|
|
513
523
|
const reason = reply.finishReason === "aborted" ? "aborted" : "max_iterations";
|
|
514
524
|
recordAgentRun({ agentId: metricAgentId, iterations, cappedOut: true });
|
|
525
|
+
if (stoppedEarly)
|
|
526
|
+
log.warn("run finished on the early cap", { iterations });
|
|
515
527
|
logFinish("warn", reason, true);
|
|
516
528
|
await giveRound();
|
|
517
529
|
return { messages, finish: { reason, cappedOut: true, truncationRecoveries, ...finishUsage() } };
|
|
@@ -552,6 +564,8 @@ export function openLoop(agent, initial, session, opts, metricAgentId) {
|
|
|
552
564
|
const endCapped = async () => {
|
|
553
565
|
settleTranscript();
|
|
554
566
|
recordAgentRun({ agentId: metricAgentId, iterations, cappedOut: true });
|
|
567
|
+
if (stoppedEarly)
|
|
568
|
+
log.warn("run finished on the early cap", { iterations });
|
|
555
569
|
logFinish("warn", "max_iterations", true);
|
|
556
570
|
await giveRound();
|
|
557
571
|
return { messages, finish: { reason: "max_iterations", cappedOut: true, truncationRecoveries, ...finishUsage() } };
|
|
@@ -54,7 +54,10 @@ export async function runToTerminal(agent, initial, session, opts, salvage) {
|
|
|
54
54
|
salvageTools: salvage.tools.map((t) => t.id),
|
|
55
55
|
salvageMaxIterations: salvageBudget,
|
|
56
56
|
});
|
|
57
|
-
|
|
57
|
+
// The early cap of the first run must not end the salvage turn, whose whole
|
|
58
|
+
// purpose is to submit after the first run stopped.
|
|
59
|
+
const { stopWhen: _stopWhen, ...continuation } = opts;
|
|
60
|
+
const salvaged = await continueAgent(agent, first.messages, { text: salvage.nudge, mask: { allow: salvage.tools.map((t) => t.id) }, maxRequests: salvageBudget, stepNamespace: "salvage" }, session, continuation);
|
|
58
61
|
// The continuation is the same logical run as the first attempt, so its
|
|
59
62
|
// rollups must cover both, or the caller reads the salvage's tokens as the whole cost.
|
|
60
63
|
//
|
package/dist/loop/types.d.ts
CHANGED
|
@@ -102,6 +102,12 @@ export type EmitEvent = {
|
|
|
102
102
|
* taken. As a result a host can fall back to its own observation.
|
|
103
103
|
*/
|
|
104
104
|
readonly durationMs?: number;
|
|
105
|
+
} | {
|
|
106
|
+
/** The display recorder of a chat turn emits it, not the loop. See `MessageStartEvent`. */
|
|
107
|
+
readonly type: "message-start";
|
|
108
|
+
readonly source: EventSource;
|
|
109
|
+
readonly messageId: string;
|
|
110
|
+
readonly role: "user" | "assistant";
|
|
105
111
|
};
|
|
106
112
|
/**
|
|
107
113
|
* A UI presentation event a tool streams to chat — an AI-SDK `data-*`
|
|
@@ -18,8 +18,6 @@ export interface ConversationDisplayRecorderOptions {
|
|
|
18
18
|
/** The root agent of the turn. Its call path is `[agentId]`, and an event with a longer path is from a sub-agent. */
|
|
19
19
|
readonly agentId: string;
|
|
20
20
|
readonly sink: EmitFn;
|
|
21
|
-
readonly userMessageId?: string;
|
|
22
|
-
readonly assistantMessageId?: string;
|
|
23
21
|
/** The clock for the times of the sub-agent activity, in milliseconds. The default is the wall clock. */
|
|
24
22
|
readonly now?: () => number;
|
|
25
23
|
}
|
|
@@ -64,8 +64,10 @@ function compactionDivider(marker) {
|
|
|
64
64
|
*/
|
|
65
65
|
export function createConversationDisplayRecorder(options) {
|
|
66
66
|
// One id for each message of the turn, thus the rounds of a turn replay as one assistant message.
|
|
67
|
-
const userMessageId =
|
|
68
|
-
let assistantMessageId =
|
|
67
|
+
const userMessageId = randomUUID();
|
|
68
|
+
let assistantMessageId = randomUUID();
|
|
69
|
+
let userAnnounced = false;
|
|
70
|
+
let announcedAssistantId;
|
|
69
71
|
const rootSource = { agentId: options.agentId, callPath: [options.agentId] };
|
|
70
72
|
// The messages of the open round. A later update of a part lands in the next round, and the replay replaces the earlier copy.
|
|
71
73
|
let round = [];
|
|
@@ -103,10 +105,33 @@ export function createConversationDisplayRecorder(options) {
|
|
|
103
105
|
}
|
|
104
106
|
apply(frame);
|
|
105
107
|
}
|
|
108
|
+
function messageStart(messageId, role) {
|
|
109
|
+
return { type: "message-start", messageId, role, source: rootSource };
|
|
110
|
+
}
|
|
111
|
+
/** The `message-start` of each id that the sink did not get yet. */
|
|
112
|
+
function announcements() {
|
|
113
|
+
const starts = [];
|
|
114
|
+
if (!userAnnounced) {
|
|
115
|
+
userAnnounced = true;
|
|
116
|
+
starts.push(messageStart(userMessageId, "user"));
|
|
117
|
+
}
|
|
118
|
+
if (announcedAssistantId !== assistantMessageId) {
|
|
119
|
+
announcedAssistantId = assistantMessageId;
|
|
120
|
+
starts.push(messageStart(assistantMessageId, "assistant"));
|
|
121
|
+
}
|
|
122
|
+
return starts;
|
|
123
|
+
}
|
|
124
|
+
// A `message-start` goes to the sink only immediately before an event: a host reads the first call of its sink
|
|
125
|
+
// as the start of the turn, and a turn that ends before any event must still get its plain refusal (for example an HTTP 402).
|
|
106
126
|
const emit = (event) => {
|
|
107
127
|
if (!finished)
|
|
108
128
|
record(event);
|
|
109
|
-
|
|
129
|
+
const starts = announcements();
|
|
130
|
+
if (starts.length === 0)
|
|
131
|
+
return options.sink(event);
|
|
132
|
+
// The calls start in the order of the stream with no await between them: a later emit that its caller does
|
|
133
|
+
// not await, for example a text delta, must not reach the sink before them.
|
|
134
|
+
return Promise.all([...starts, event].map((next) => options.sink(next))).then(() => undefined);
|
|
110
135
|
};
|
|
111
136
|
function takeOpening() {
|
|
112
137
|
return [{ id: userMessageId, role: "user", parts: [{ type: "text", text: options.userText, state: "done" }] }];
|
|
@@ -29,7 +29,7 @@ import type { Logger } from "../lib/logger.js";
|
|
|
29
29
|
import { type StoredMessageEnvelope } from "./ai-sdk-message-storage.js";
|
|
30
30
|
import type { MessagePart } from "../contracts/message.js";
|
|
31
31
|
import { type ConversationUIMessage, type StoredDisplayEnvelope } from "./conversation-display-storage.js";
|
|
32
|
-
/** One stored message, as returned by the display
|
|
32
|
+
/** One stored message, as returned by the display reads (`loadAll` and `loadPage`). */
|
|
33
33
|
export interface StoredMessage {
|
|
34
34
|
readonly seq: number;
|
|
35
35
|
readonly envelope: StoredMessageEnvelope;
|
|
@@ -172,6 +172,20 @@ export interface LoadRecentOptions {
|
|
|
172
172
|
/** Keep the first turn of the thread, the seed of a report thread, in front of the latest summary. */
|
|
173
173
|
readonly keepFirstTurn?: boolean;
|
|
174
174
|
}
|
|
175
|
+
/** The options of {@link ThreadHistory.loadPage}. */
|
|
176
|
+
export interface LoadPageOptions {
|
|
177
|
+
/** The cursor that the previous page gave. Absent for the newest page. */
|
|
178
|
+
readonly before?: number;
|
|
179
|
+
/** The maximum count of turns in the page. */
|
|
180
|
+
readonly limit: number;
|
|
181
|
+
}
|
|
182
|
+
/** One page of the display read, as {@link ThreadHistory.loadPage} gives it. */
|
|
183
|
+
export interface TurnPage {
|
|
184
|
+
/** The whole turns of the page, oldest-first. */
|
|
185
|
+
readonly turns: StoredMessage[][];
|
|
186
|
+
/** The cursor of the next older page, or `null` when the page holds the oldest row of the thread. */
|
|
187
|
+
readonly before: number | null;
|
|
188
|
+
}
|
|
175
189
|
/**
|
|
176
190
|
* The conversation message store. Two methods, by design — no generic row
|
|
177
191
|
* insert (see the harness-thread-store spec). `threadId` is the conversation scope — one UI thread.
|
|
@@ -195,12 +209,8 @@ export interface ThreadHistory {
|
|
|
195
209
|
/**
|
|
196
210
|
* Return a thread's messages oldest-first for UI display, grouped into turns.
|
|
197
211
|
* It gives each row, and the view of the agent loop is `loadRecent`'s job.
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
* selects and parses every row of the thread and would slice only afterwards,
|
|
201
|
-
* so a page saves no query and no parse. It would only truncate the answer,
|
|
202
|
-
* which is a way to lose turns and not a way to save work. A reader wanting a
|
|
203
|
-
* window takes one here, where it knows what it is bounding.
|
|
212
|
+
* It selects and parses every row of the thread, thus its work grows with the
|
|
213
|
+
* thread. {@link ThreadHistory.loadPage} bounds that work.
|
|
204
214
|
*
|
|
205
215
|
* Grouped rather than flat because the grouping is what the read computes and
|
|
206
216
|
* what a turn count means, and `.flat()` recovers the rows in `seq` order for
|
|
@@ -209,6 +219,23 @@ export interface ThreadHistory {
|
|
|
209
219
|
* this read already found.
|
|
210
220
|
*/
|
|
211
221
|
loadAll(threadId: string): ResultAsync<StoredMessage[][], DbError>;
|
|
222
|
+
/**
|
|
223
|
+
* Return the newest whole turns of a thread for UI display, up to `limit` turns, oldest-first.
|
|
224
|
+
* With `before`, it returns the newest whole turns below the turn that the cursor names.
|
|
225
|
+
* The rows are the same as the rows of {@link ThreadHistory.loadAll}.
|
|
226
|
+
*
|
|
227
|
+
* The read selects and parses only the rows of its page, thus its work does not grow with
|
|
228
|
+
* the turns before the page. The page edges inside the thread are turn starts, so the read
|
|
229
|
+
* never truncates a turn, also for a cursor that is not a turn start: the turn of the cursor
|
|
230
|
+
* row is then the newest turn of the page, and it is whole. The rows before the first turn
|
|
231
|
+
* start make one group, and the group counts as a turn.
|
|
232
|
+
*
|
|
233
|
+
* The cursor is the `seq` of the opening row of the oldest turn of the page. A `seq` never
|
|
234
|
+
* moves and a retract removes only the tail, thus a cursor stays correct across a later
|
|
235
|
+
* append and a later retract. A `limit` that is not finite reads as 1, and each other
|
|
236
|
+
* `limit` reads as its integer part with a minimum of 1.
|
|
237
|
+
*/
|
|
238
|
+
loadPage(threadId: string, options: LoadPageOptions): ResultAsync<TurnPage, DbError>;
|
|
212
239
|
/**
|
|
213
240
|
* Remove the thread's most recent turn — every row from the last
|
|
214
241
|
* genuine-user-start `seq` onward, and its turn record — in a single transaction.
|
|
@@ -224,6 +224,102 @@ function closeTurnRecord(client, threadId, startSeq, close) {
|
|
|
224
224
|
SET status = $3, reason = $4, reported_usage = $5::jsonb, turn_duration_ms = $6, closed_at = NOW()
|
|
225
225
|
WHERE thread_id = $1 AND start_seq = $2 AND status = 'open'`, [threadId, startSeq, close.status, close.reason === undefined ? null : stripNulCharacters(close.reason), rollup, close.turnDurationMs ?? null])).map(() => undefined);
|
|
226
226
|
}
|
|
227
|
+
/** The columns of a display-read row, and the join that the turn record needs. Both display reads select them. */
|
|
228
|
+
const STORED_ROW_COLUMNS = `messages.seq::text AS seq, messages.message_envelope, messages.display_envelope, messages.reported_usage,
|
|
229
|
+
messages.turn_duration_ms::text AS turn_duration_ms, messages.author, messages.created_at,
|
|
230
|
+
turns.status AS turn_status, turns.reason AS turn_reason, turns.reported_usage AS turn_usage,
|
|
231
|
+
turns.turn_duration_ms::text AS turn_record_duration_ms`;
|
|
232
|
+
function storedRowFrom(turnJoinBounds = "") {
|
|
233
|
+
return `FROM messages
|
|
234
|
+
LEFT JOIN cortex_thread_turns turns ON turns.thread_id = messages.thread_id AND turns.start_seq = messages.seq${turnJoinBounds}`;
|
|
235
|
+
}
|
|
236
|
+
/** The largest `bigint`, the bound of a cursor that the caller did not give. */
|
|
237
|
+
const MAX_SEQ = "9223372036854775807";
|
|
238
|
+
/** The smallest `bigint`, below every `seq`. */
|
|
239
|
+
const MIN_SEQ = "'-9223372036854775808'";
|
|
240
|
+
const PAGE_LOWER_BOUND = `COALESCE((SELECT seq FROM page_lower), ${MIN_SEQ}::bigint)`;
|
|
241
|
+
const PAGE_UPPER_BOUND = `COALESCE((SELECT seq FROM page_upper), ${MAX_SEQ}::bigint)`;
|
|
242
|
+
/**
|
|
243
|
+
* The statement of one page of the display read (`$1` thread, `$2` cursor or `NULL`, `$3` limit).
|
|
244
|
+
*
|
|
245
|
+
* One statement reads one snapshot, thus an append or a retract cannot add a turn between the
|
|
246
|
+
* read of the page edges and the read of the rows. The parts:
|
|
247
|
+
*
|
|
248
|
+
* - `page_walk`: a walk down from the cursor, one row for each step, that counts the turn starts
|
|
249
|
+
* and stops at the `$3`-th. Each step is a `LIMIT 1` read of the primary key `(thread_id, seq)`
|
|
250
|
+
* that evaluates the start predicate in the select list.
|
|
251
|
+
* - `page_lower`: the row where the walk reached `$3` starts, else `NULL`. A page that is not
|
|
252
|
+
* full reaches the start of the thread and holds the rows before the first turn start.
|
|
253
|
+
* - `page_climb` and `page_upper`: a walk up from the cursor to the first turn start at or above
|
|
254
|
+
* it, else `NULL`. For a cursor that a page gave this is the cursor row. For a cursor inside a
|
|
255
|
+
* turn it is the start of the next turn, thus the turn of the cursor stays whole.
|
|
256
|
+
* - `page_next`: `page_lower` when a row lies below it. The `EXISTS` reads no envelope.
|
|
257
|
+
*
|
|
258
|
+
* The start predicate is never a `WHERE` condition of a scan that has an `ORDER BY` and a `LIMIT`.
|
|
259
|
+
* The planner has no statistics for a JSON path, so it estimates that very few rows match. It then
|
|
260
|
+
* chooses a bitmap scan and a sort of each row of the thread, and it evaluates the predicate on the
|
|
261
|
+
* envelope of each row (a tool result can be many KB). A walk of `LIMIT 1` reads of the key does
|
|
262
|
+
* not depend on that estimate, and its work grows with the rows of the page.
|
|
263
|
+
*
|
|
264
|
+
* An absent cursor, `page_lower`, and `page_upper` each become a `bigint` extreme through
|
|
265
|
+
* `COALESCE`, never a `NULL` in a comparison: `seq >= NULL` matches no row, and a form such as
|
|
266
|
+
* `($2 IS NULL OR seq < $2)` is a filter, which would visit each newer row. Each bound is an
|
|
267
|
+
* index condition on `(thread_id, seq)`. The join to the turn records carries the same bounds,
|
|
268
|
+
* because the planner estimates a bounded range as a fixed fraction of the thread and then reads
|
|
269
|
+
* each turn record of the thread through a hash join.
|
|
270
|
+
*/
|
|
271
|
+
const PAGE_SQL = `WITH RECURSIVE page_walk(seq, starts) AS (
|
|
272
|
+
SELECT COALESCE($2::bigint, ${MAX_SEQ}::bigint), 0::bigint
|
|
273
|
+
UNION ALL
|
|
274
|
+
SELECT below.seq, page_walk.starts + below.is_start::int
|
|
275
|
+
FROM page_walk
|
|
276
|
+
CROSS JOIN LATERAL (
|
|
277
|
+
SELECT messages.seq, COALESCE(${GENUINE_USER_START_SQL}, false) AS is_start FROM messages
|
|
278
|
+
WHERE messages.thread_id = $1 AND messages.seq < page_walk.seq
|
|
279
|
+
ORDER BY messages.seq DESC
|
|
280
|
+
LIMIT 1
|
|
281
|
+
) below
|
|
282
|
+
WHERE page_walk.starts < $3::bigint
|
|
283
|
+
),
|
|
284
|
+
page_lower AS (
|
|
285
|
+
SELECT (SELECT page_walk.seq FROM page_walk WHERE page_walk.starts = $3::bigint) AS seq
|
|
286
|
+
),
|
|
287
|
+
page_climb(seq, is_start) AS (
|
|
288
|
+
SELECT above.seq, above.is_start FROM (
|
|
289
|
+
SELECT messages.seq, COALESCE(${GENUINE_USER_START_SQL}, false) AS is_start FROM messages
|
|
290
|
+
WHERE messages.thread_id = $1 AND messages.seq >= COALESCE($2::bigint, ${MAX_SEQ}::bigint)
|
|
291
|
+
ORDER BY messages.seq ASC
|
|
292
|
+
LIMIT 1
|
|
293
|
+
) above
|
|
294
|
+
UNION ALL
|
|
295
|
+
SELECT above.seq, above.is_start
|
|
296
|
+
FROM page_climb
|
|
297
|
+
CROSS JOIN LATERAL (
|
|
298
|
+
SELECT messages.seq, COALESCE(${GENUINE_USER_START_SQL}, false) AS is_start FROM messages
|
|
299
|
+
WHERE messages.thread_id = $1 AND messages.seq > page_climb.seq
|
|
300
|
+
ORDER BY messages.seq ASC
|
|
301
|
+
LIMIT 1
|
|
302
|
+
) above
|
|
303
|
+
WHERE NOT page_climb.is_start
|
|
304
|
+
),
|
|
305
|
+
page_upper AS (
|
|
306
|
+
SELECT page_climb.seq FROM page_climb WHERE page_climb.is_start
|
|
307
|
+
),
|
|
308
|
+
page_next AS (
|
|
309
|
+
SELECT page_lower.seq FROM page_lower
|
|
310
|
+
WHERE page_lower.seq IS NOT NULL
|
|
311
|
+
AND EXISTS (SELECT 1 FROM messages WHERE thread_id = $1 AND seq < page_lower.seq)
|
|
312
|
+
)
|
|
313
|
+
SELECT ${STORED_ROW_COLUMNS}, (SELECT seq::text FROM page_next) AS next_before
|
|
314
|
+
${storedRowFrom(` AND turns.start_seq >= ${PAGE_LOWER_BOUND} AND turns.start_seq < ${PAGE_UPPER_BOUND}`)}
|
|
315
|
+
WHERE messages.thread_id = $1
|
|
316
|
+
AND messages.seq >= ${PAGE_LOWER_BOUND}
|
|
317
|
+
AND messages.seq < ${PAGE_UPPER_BOUND}
|
|
318
|
+
ORDER BY messages.seq ASC`;
|
|
319
|
+
/** The limit that the page statement receives: an integer, at least 1, and a bigint-safe size. */
|
|
320
|
+
function pageLimit(limit) {
|
|
321
|
+
return Number.isFinite(limit) ? Math.min(Math.max(1, Math.trunc(limit)), Number.MAX_SAFE_INTEGER) : 1;
|
|
322
|
+
}
|
|
227
323
|
/**
|
|
228
324
|
* Create a `ThreadHistory` bound to a Postgres pool — a factory closure
|
|
229
325
|
* capturing `pool` (dependency injection per the harness-durable-runtime spec). The `messages` table is
|
|
@@ -296,59 +392,72 @@ export function createThreadHistory(pool, logger) {
|
|
|
296
392
|
return view.messages;
|
|
297
393
|
});
|
|
298
394
|
}
|
|
299
|
-
//
|
|
300
|
-
// turn is persisted as one row per step (plus its tool_result
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
395
|
+
// A display read is turn-bounded, not row-bounded. A serial-tool assistant
|
|
396
|
+
// turn is persisted as one row per step (plus its tool_result rows), so a
|
|
397
|
+
// row-windowed read could split a turn and truncate the trailing report
|
|
398
|
+
// card or text out of what the UI fetches. Both reads group rows into turns,
|
|
399
|
+
// so a turn always reloads intact.
|
|
400
|
+
// Spread rather than `usage: r.reported_usage ?? undefined`, so a row with
|
|
401
|
+
// no rollup carries no `usage` KEY at all — absent, not present-and-undefined.
|
|
402
|
+
// The duration and the author obey the same rule, each from its own column.
|
|
403
|
+
// The creation time is unconditional: its column is NOT NULL, thus it has no
|
|
404
|
+
// absence to represent.
|
|
405
|
+
async function toStoredMessage(threadId, r) {
|
|
406
|
+
const envelope = parseStoredMessageEnvelope(r.message_envelope, `${threadId}/${r.seq}`);
|
|
407
|
+
const displayEnvelope = r.display_envelope == null ? undefined : await parseStoredDisplayEnvelope(r.display_envelope, `${threadId}/${r.seq}/display`, logger);
|
|
408
|
+
const turn = r.turn_status === null
|
|
409
|
+
? undefined
|
|
410
|
+
: {
|
|
411
|
+
status: r.turn_status,
|
|
412
|
+
...(r.turn_reason === null ? {} : { reason: r.turn_reason }),
|
|
413
|
+
...(r.turn_usage === null ? {} : { usage: r.turn_usage }),
|
|
414
|
+
...(r.turn_record_duration_ms === null ? {} : { durationMs: Number(r.turn_record_duration_ms) }),
|
|
415
|
+
};
|
|
416
|
+
return {
|
|
417
|
+
seq: Number(r.seq),
|
|
418
|
+
envelope,
|
|
419
|
+
message: envelope.message,
|
|
420
|
+
...(displayEnvelope ? { displayEnvelope } : {}),
|
|
421
|
+
...(r.reported_usage === null ? {} : { usage: r.reported_usage }),
|
|
422
|
+
...(r.turn_duration_ms === null ? {} : { durationMs: Number(r.turn_duration_ms) }),
|
|
423
|
+
...(turn === undefined ? {} : { turn }),
|
|
424
|
+
...(r.author === null ? {} : { author: r.author }),
|
|
425
|
+
createdAt: r.created_at,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
async function toTurns(threadId, rows) {
|
|
429
|
+
const stored = await Promise.all(rows.map((row) => toStoredMessage(threadId, row)));
|
|
430
|
+
return groupTurns(stored, (row) => isGenuineUserStart(row.message));
|
|
431
|
+
}
|
|
305
432
|
async function readTurns(threadId) {
|
|
306
433
|
const { rows } = await pool.query(
|
|
307
434
|
// ORDER BY must qualify `messages.seq` — a bare `seq` would bind to the
|
|
308
435
|
// `seq::text AS seq` output alias (Postgres resolves an unqualified
|
|
309
436
|
// ORDER BY name to the output column), sorting the bigint as text:
|
|
310
437
|
// "10" before "2". The qualified name forces the bigint column.
|
|
311
|
-
`SELECT
|
|
312
|
-
|
|
313
|
-
turns.status AS turn_status, turns.reason AS turn_reason, turns.reported_usage AS turn_usage,
|
|
314
|
-
turns.turn_duration_ms::text AS turn_record_duration_ms
|
|
315
|
-
FROM messages
|
|
316
|
-
LEFT JOIN cortex_thread_turns turns ON turns.thread_id = messages.thread_id AND turns.start_seq = messages.seq
|
|
438
|
+
`SELECT ${STORED_ROW_COLUMNS}
|
|
439
|
+
${storedRowFrom()}
|
|
317
440
|
WHERE messages.thread_id = $1
|
|
318
441
|
ORDER BY messages.seq ASC`, [threadId]);
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
...(r.turn_reason === null ? {} : { reason: r.turn_reason }),
|
|
332
|
-
...(r.turn_usage === null ? {} : { usage: r.turn_usage }),
|
|
333
|
-
...(r.turn_record_duration_ms === null ? {} : { durationMs: Number(r.turn_record_duration_ms) }),
|
|
334
|
-
};
|
|
335
|
-
return {
|
|
336
|
-
seq: Number(r.seq),
|
|
337
|
-
envelope,
|
|
338
|
-
message: envelope.message,
|
|
339
|
-
...(displayEnvelope ? { displayEnvelope } : {}),
|
|
340
|
-
...(r.reported_usage === null ? {} : { usage: r.reported_usage }),
|
|
341
|
-
...(r.turn_duration_ms === null ? {} : { durationMs: Number(r.turn_duration_ms) }),
|
|
342
|
-
...(turn === undefined ? {} : { turn }),
|
|
343
|
-
...(r.author === null ? {} : { author: r.author }),
|
|
344
|
-
createdAt: r.created_at,
|
|
345
|
-
};
|
|
346
|
-
}));
|
|
347
|
-
return groupTurns(stored, (row) => isGenuineUserStart(row.message));
|
|
442
|
+
return toTurns(threadId, rows);
|
|
443
|
+
}
|
|
444
|
+
async function readPage(threadId, options) {
|
|
445
|
+
const { rows } = await pool.query(PAGE_SQL, [
|
|
446
|
+
threadId,
|
|
447
|
+
options.before === undefined ? null : String(options.before),
|
|
448
|
+
pageLimit(options.limit),
|
|
449
|
+
]);
|
|
450
|
+
// The cursor rides each row, and an empty page has no row. An empty page has no cursor either:
|
|
451
|
+
// `page_lower` is set only when its row is in the page.
|
|
452
|
+
const next = rows[0]?.next_before ?? null;
|
|
453
|
+
return { turns: await toTurns(threadId, rows), before: next === null ? null : Number(next) };
|
|
348
454
|
}
|
|
349
455
|
function loadAll(threadId) {
|
|
350
456
|
return tryQuery("thread-history.loadAll", () => readTurns(threadId));
|
|
351
457
|
}
|
|
458
|
+
function loadPage(threadId, options) {
|
|
459
|
+
return tryQuery("thread-history.loadPage", () => readPage(threadId, options));
|
|
460
|
+
}
|
|
352
461
|
function retractLastTurn(threadId) {
|
|
353
462
|
return withTransaction(pool, "thread-history.retractLastTurn", (client) =>
|
|
354
463
|
// Take the SAME per-thread advisory lock `appendTurn` takes, and take it
|
|
@@ -426,5 +535,5 @@ export function createThreadHistory(pool, logger) {
|
|
|
426
535
|
AND seq > $2::bigint
|
|
427
536
|
AND ${GENUINE_USER_START_SQL}`, [threadId, seq])).map(({ rows }) => Number(rows[0].turns));
|
|
428
537
|
}
|
|
429
|
-
return { appendTurn, writeTurn, loadRecent, loadAll, retractLastTurn, latestSeq, latestTurnAt, countUserTurnsAfter };
|
|
538
|
+
return { appendTurn, writeTurn, loadRecent, loadAll, loadPage, retractLastTurn, latestSeq, latestTurnAt, countUserTurnsAfter };
|
|
430
539
|
}
|