@inflexa-ai/harness 0.14.1 → 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/README.md +2 -1
- package/dist/agents/conversation-agent.d.ts +7 -0
- package/dist/agents/conversation-agent.js +5 -3
- package/dist/app/synthesize-run.d.ts +3 -0
- package/dist/app/synthesize-run.js +2 -1
- package/dist/billing/noop-usage-recorder.d.ts +7 -0
- package/dist/billing/noop-usage-recorder.js +10 -0
- package/dist/billing/usage-recorder.d.ts +63 -0
- package/dist/billing/usage-recorder.js +15 -0
- package/dist/contracts/chat-events.d.ts +44 -3
- package/dist/contracts/chat-parts.d.ts +38 -2
- package/dist/contracts/chat-parts.js +1 -1
- package/dist/contracts/data-profile.d.ts +26 -0
- package/dist/contracts/data-profile.js +26 -0
- package/dist/contracts/index.d.ts +3 -1
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/message.d.ts +21 -1
- package/dist/contracts/part-registry.js +1 -0
- package/dist/contracts/schemas/chat-events.d.ts +48 -2
- package/dist/contracts/schemas/chat-events.js +8 -1
- package/dist/contracts/schemas/chat-parts.d.ts +46 -3
- package/dist/contracts/schemas/chat-parts.js +13 -0
- package/dist/contracts/schemas/index.d.ts +2 -1
- package/dist/contracts/schemas/index.js +2 -1
- package/dist/contracts/schemas/usage.d.ts +11 -0
- package/dist/contracts/schemas/usage.js +18 -0
- package/dist/contracts/usage.d.ts +51 -0
- package/dist/contracts/usage.js +41 -0
- package/dist/execution/artifact-metadata.d.ts +3 -0
- package/dist/execution/artifact-metadata.js +1 -0
- package/dist/execution/dbos-workflow-purger.d.ts +30 -0
- package/dist/execution/dbos-workflow-purger.js +193 -0
- package/dist/execution/post-step-pipeline.d.ts +3 -0
- package/dist/execution/post-step-pipeline.js +2 -0
- package/dist/execution/reconcile-manifest.js +28 -15
- package/dist/execution/report-runner.d.ts +16 -0
- package/dist/execution/report-runner.js +4 -0
- package/dist/execution/run-synthesis.d.ts +3 -0
- package/dist/execution/run-synthesis.js +2 -0
- package/dist/execution/step-summary.d.ts +3 -0
- package/dist/execution/step-summary.js +1 -0
- package/dist/execution/workflow-purger.d.ts +38 -0
- package/dist/execution/workflow-purger.js +24 -0
- package/dist/index.d.ts +25 -3
- package/dist/index.js +28 -1
- package/dist/lib/metrics.js +2 -1
- package/dist/loop/metrics.d.ts +18 -0
- package/dist/loop/metrics.js +18 -1
- package/dist/loop/run-agent.d.ts +35 -0
- package/dist/loop/run-agent.js +149 -23
- package/dist/loop/run-to-terminal.d.ts +5 -2
- package/dist/loop/run-to-terminal.js +36 -2
- package/dist/loop/tool-detail.d.ts +54 -0
- package/dist/loop/tool-detail.js +105 -0
- package/dist/loop/tool-outcome.d.ts +21 -0
- package/dist/loop/tool-outcome.js +25 -0
- package/dist/loop/types.d.ts +15 -1
- package/dist/memory/content-to-cortex.d.ts +28 -4
- package/dist/memory/content-to-cortex.js +70 -14
- package/dist/memory/thread-history.d.ts +21 -1
- package/dist/memory/thread-history.js +29 -7
- package/dist/memory/thread-store.d.ts +65 -8
- package/dist/memory/thread-store.js +81 -17
- package/dist/provenance/collector.d.ts +5 -3
- package/dist/provenance/collector.js +5 -3
- package/dist/providers/ai-sdk.d.ts +18 -0
- package/dist/providers/ai-sdk.js +129 -16
- package/dist/providers/anthropic.d.ts +2 -0
- package/dist/providers/anthropic.js +1 -0
- package/dist/providers/streaming-chat.js +5 -0
- package/dist/providers/types.d.ts +23 -0
- package/dist/providers/types.js +1 -0
- package/dist/runtime/assemble.d.ts +25 -8
- package/dist/runtime/assemble.js +7 -4
- package/dist/sandbox/sandbox-step-translate.d.ts +22 -1
- package/dist/sandbox/sandbox-step-translate.js +23 -36
- package/dist/schemas/run-synthesis.d.ts +2 -2
- package/dist/state/index.d.ts +2 -0
- package/dist/state/index.js +3 -0
- package/dist/state/init.js +28 -0
- package/dist/state/purge-analysis.d.ts +74 -0
- package/dist/state/purge-analysis.js +205 -0
- package/dist/state/vector-store.d.ts +6 -0
- package/dist/state/vector-store.js +22 -2
- package/dist/tasks/data-profile-activity.d.ts +14 -2
- package/dist/tasks/data-profile-activity.js +1 -1
- package/dist/tasks/data-profile.d.ts +3 -0
- package/dist/tasks/data-profile.js +9 -3
- package/dist/tools/bio/pubmed.js +15 -0
- package/dist/tools/bio/search-gene.js +12 -0
- package/dist/tools/define-tool.d.ts +41 -3
- package/dist/tools/define-tool.js +4 -1
- package/dist/tools/detail-resolver.d.ts +35 -0
- package/dist/tools/detail-resolver.js +39 -0
- package/dist/tools/execute-analysis.js +3 -0
- package/dist/tools/iterate-report.d.ts +3 -0
- package/dist/tools/iterate-report.js +3 -0
- package/dist/tools/memory/update-working-memory.js +8 -0
- package/dist/tools/research/generate-analogy-report.d.ts +3 -0
- package/dist/tools/research/generate-analogy-report.js +6 -0
- package/dist/tools/research/generate-plan.d.ts +3 -0
- package/dist/tools/research/generate-plan.js +6 -0
- package/dist/tools/research/inspect-run.js +4 -0
- package/dist/tools/research/literature-reviewer.d.ts +3 -0
- package/dist/tools/research/literature-reviewer.js +6 -0
- package/dist/tools/workspace/edit-file.js +2 -0
- package/dist/tools/workspace/execute-command.js +25 -0
- package/dist/tools/workspace/grep.js +3 -0
- package/dist/tools/workspace/list-files.js +2 -0
- package/dist/tools/workspace/read-file.js +7 -0
- package/dist/tools/workspace/workspace-search.js +1 -0
- package/dist/tools/workspace/write-file.js +3 -0
- package/dist/workflows/execute-analysis.d.ts +7 -0
- package/dist/workflows/execute-analysis.js +31 -2
- package/dist/workflows/execute-target-assessment.d.ts +3 -0
- package/dist/workflows/sandbox-step.d.ts +23 -0
- package/dist/workflows/sandbox-step.js +33 -2
- package/dist/workflows/target-assessment/lib/clinical-consequence-annotator.d.ts +3 -0
- package/dist/workflows/target-assessment/lib/clinical-consequence-annotator.js +1 -0
- package/package.json +2 -2
package/dist/state/init.js
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { backfillAiSdkMessageEnvelopes } from "../memory/message-backfill.js";
|
|
10
10
|
import { createNoopLogger } from "../lib/console-logger.js";
|
|
11
|
+
// No ';' may appear anywhere in this text, comments included: it is executed by
|
|
12
|
+
// splitting on ';' (see `DDL.split` below), so a semicolon inside a comment cuts
|
|
13
|
+
// the statement it precedes in half and Postgres rejects the fragment.
|
|
11
14
|
const DDL = `
|
|
12
15
|
-- Analysis-level state (status, context, data-profile tracking, billing identity).
|
|
13
16
|
--
|
|
@@ -227,6 +230,25 @@ CREATE INDEX IF NOT EXISTS idx_cortex_regulatory_chunks_metadata
|
|
|
227
230
|
-- re-sent to the provider on the next turn, where a reordered prefix misses the
|
|
228
231
|
-- prompt cache. JSON stores the text verbatim. Nothing indexes this column, and
|
|
229
232
|
-- the JSON operators retractLastTurn uses work the same on either type.
|
|
233
|
+
--
|
|
234
|
+
-- reported_usage holds a whole turn's TokenUsageRollup — what providers reported
|
|
235
|
+
-- for the turn this row completed — and is written only on the LAST assistant row
|
|
236
|
+
-- of a turn, so a reloaded transcript can show the figure the live turn showed.
|
|
237
|
+
-- It is NOT the tokens column beside it, and the two must never be read for
|
|
238
|
+
-- each other: tokens is an offline js-tiktoken estimate stamped on EVERY row so
|
|
239
|
+
-- the read path can window by budget without a provider round trip, and it exists
|
|
240
|
+
-- for rows no provider ever saw. reported_usage is a provider's own report, and
|
|
241
|
+
-- covers a turn rather than a row. Both are plausible-looking integers on one row,
|
|
242
|
+
-- which is exactly why the column names carry the distinction.
|
|
243
|
+
--
|
|
244
|
+
-- Nullable with no default: absent means nothing was reported, and a zero would
|
|
245
|
+
-- claim a turn cost nothing. A row written before this column existed is absent
|
|
246
|
+
-- for the same reason, so the migration needs no backfill.
|
|
247
|
+
--
|
|
248
|
+
-- JSONB here, unlike message_envelope above. The verbatim-bytes requirement that
|
|
249
|
+
-- forces JSON on the envelope is about model content that is re-sent to the
|
|
250
|
+
-- provider. This record is harness-generated, has a fixed field set, and never
|
|
251
|
+
-- reaches a prompt, so it does not share that requirement.
|
|
230
252
|
CREATE TABLE IF NOT EXISTS messages (
|
|
231
253
|
thread_id TEXT NOT NULL,
|
|
232
254
|
seq BIGINT NOT NULL,
|
|
@@ -234,6 +256,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
|
|
234
256
|
content_jsonb JSONB,
|
|
235
257
|
message_envelope JSON,
|
|
236
258
|
tokens INTEGER NOT NULL,
|
|
259
|
+
reported_usage JSONB,
|
|
237
260
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
238
261
|
PRIMARY KEY (thread_id, seq)
|
|
239
262
|
);
|
|
@@ -432,6 +455,11 @@ export async function initCortexState(pool, injected) {
|
|
|
432
455
|
// dropping an absent NOT NULL no-ops, so this is a no-op on fresh DBs.
|
|
433
456
|
"ALTER TABLE cortex_analysis_state ALTER COLUMN data_profile_status DROP NOT NULL",
|
|
434
457
|
"ALTER TABLE messages ADD COLUMN IF NOT EXISTS message_envelope JSON",
|
|
458
|
+
// The turn's reported usage rollup (see the CREATE TABLE comment).
|
|
459
|
+
// Purely additive: nullable with no default, so adding it rewrites no
|
|
460
|
+
// row and every pre-existing message reads back with no rollup — which
|
|
461
|
+
// is the honest value, the figures having never been recorded.
|
|
462
|
+
"ALTER TABLE messages ADD COLUMN IF NOT EXISTS reported_usage JSONB",
|
|
435
463
|
// Demote an existing JSONB envelope column to JSON (see the CREATE
|
|
436
464
|
// TABLE comment). Type-guarded because ALTER ... TYPE rewrites the
|
|
437
465
|
// whole table and this runs at every boot; scoped to
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* purgeAnalysis — the single place that knows what an analysis's persisted
|
|
3
|
+
* Postgres footprint is.
|
|
4
|
+
*
|
|
5
|
+
* A host asks for it with an `analysisId` and nothing else: every keyed store,
|
|
6
|
+
* the analysis's dynamic vector-table name, and its workflow ids are all
|
|
7
|
+
* derivable from that id, so no caller enumerates tables or supplies a run list.
|
|
8
|
+
* The workflow ledger is part of the footprint, not a follow-on — the sandbox
|
|
9
|
+
* transcripts and run-event streams live there and are the dominant share of an
|
|
10
|
+
* analysis's stored bytes — and it is reached through the injected
|
|
11
|
+
* `WorkflowPurger` seam so the durability engine stays out of the state layer.
|
|
12
|
+
*
|
|
13
|
+
* There is no single transaction across the stages: the purge spans app tables, a
|
|
14
|
+
* DDL `DROP TABLE`, and the engine's system schema, so instead of faking atomicity
|
|
15
|
+
* every stage is idempotent and the whole operation is safe to re-run. A purge of
|
|
16
|
+
* an unknown or already-purged analysis therefore succeeds, reporting zeroes. Each
|
|
17
|
+
* stage carries the reason it sits where it does.
|
|
18
|
+
*
|
|
19
|
+
* A purge is NOT serialized against work still starting on the analysis. The
|
|
20
|
+
* mapping from an analysis to its workflows is read once, up front, out of
|
|
21
|
+
* `cortex_runs`; a run inserted after that read is outside the captured set, and
|
|
22
|
+
* the `cortex_runs` delete later in the same purge removes the only row that could
|
|
23
|
+
* ever have named it. Its workflow row and every byte cascading off it then belong
|
|
24
|
+
* to no analysis — no retry reaches them, because nothing is left that attributes
|
|
25
|
+
* them to one. Quiesce the analysis before purging it: no new runs, no new
|
|
26
|
+
* data-profile triggers. This module cannot observe a host's in-flight work, so it
|
|
27
|
+
* does not enforce that.
|
|
28
|
+
*
|
|
29
|
+
* What it does not reach, so that absent coverage is never read as delivered:
|
|
30
|
+
* scheduled operational workflows (they belong to no analysis and accumulate
|
|
31
|
+
* independently of any purge), target assessments and their annotations (a
|
|
32
|
+
* separate top-level entity), the shared regulatory corpus, `messages` rows whose
|
|
33
|
+
* thread row is already gone (nothing attributes them to an analysis), and
|
|
34
|
+
* workspace files on disk (the embedder owns their disposal).
|
|
35
|
+
*/
|
|
36
|
+
import { ResultAsync } from "neverthrow";
|
|
37
|
+
import type { Pool } from "pg";
|
|
38
|
+
import type { WorkflowPurger } from "../execution/workflow-purger.js";
|
|
39
|
+
import { type DbError } from "../lib/db-result.js";
|
|
40
|
+
import type { Logger } from "../lib/logger.js";
|
|
41
|
+
/**
|
|
42
|
+
* What one invocation reclaimed. It is a report of this call, not a claim that the
|
|
43
|
+
* analysis is now empty: a re-run still reclaims whatever a failed stage left
|
|
44
|
+
* behind, and rows written under a workflow the purge already deleted, which trip
|
|
45
|
+
* their foreign key.
|
|
46
|
+
*/
|
|
47
|
+
export interface AnalysisPurgeOutcome {
|
|
48
|
+
readonly threads: number;
|
|
49
|
+
readonly messages: number;
|
|
50
|
+
/**
|
|
51
|
+
* How many workflows the ledger delete targeted, counted immediately before it
|
|
52
|
+
* and outside any transaction, because the engine owns the delete and reports no
|
|
53
|
+
* count of its own. It is not a post-delete tally: a row arriving between the
|
|
54
|
+
* count and the delete is deleted without being counted.
|
|
55
|
+
*/
|
|
56
|
+
readonly workflows: number;
|
|
57
|
+
readonly vectorIndexDropped: boolean;
|
|
58
|
+
}
|
|
59
|
+
export interface AnalysisPurgeDeps {
|
|
60
|
+
readonly pool: Pool;
|
|
61
|
+
/** The workflow-ledger seam. Cancellation and deletion by id, engine-agnostic. */
|
|
62
|
+
readonly workflows: WorkflowPurger;
|
|
63
|
+
readonly logger?: Logger;
|
|
64
|
+
}
|
|
65
|
+
export interface AnalysisPurge {
|
|
66
|
+
/**
|
|
67
|
+
* Remove the analysis's entire persisted Postgres footprint. Absence is a
|
|
68
|
+
* normal outcome: an unknown or already-purged analysis succeeds with zeroes.
|
|
69
|
+
* Every failure rides the error channel — the operation never reports a purge
|
|
70
|
+
* it did not achieve. The caller quiesces the analysis first (module note).
|
|
71
|
+
*/
|
|
72
|
+
purgeAnalysis(analysisId: string): ResultAsync<AnalysisPurgeOutcome, DbError>;
|
|
73
|
+
}
|
|
74
|
+
export declare function createAnalysisPurge({ pool, workflows, logger: injected }: AnalysisPurgeDeps): AnalysisPurge;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* purgeAnalysis — the single place that knows what an analysis's persisted
|
|
3
|
+
* Postgres footprint is.
|
|
4
|
+
*
|
|
5
|
+
* A host asks for it with an `analysisId` and nothing else: every keyed store,
|
|
6
|
+
* the analysis's dynamic vector-table name, and its workflow ids are all
|
|
7
|
+
* derivable from that id, so no caller enumerates tables or supplies a run list.
|
|
8
|
+
* The workflow ledger is part of the footprint, not a follow-on — the sandbox
|
|
9
|
+
* transcripts and run-event streams live there and are the dominant share of an
|
|
10
|
+
* analysis's stored bytes — and it is reached through the injected
|
|
11
|
+
* `WorkflowPurger` seam so the durability engine stays out of the state layer.
|
|
12
|
+
*
|
|
13
|
+
* There is no single transaction across the stages: the purge spans app tables, a
|
|
14
|
+
* DDL `DROP TABLE`, and the engine's system schema, so instead of faking atomicity
|
|
15
|
+
* every stage is idempotent and the whole operation is safe to re-run. A purge of
|
|
16
|
+
* an unknown or already-purged analysis therefore succeeds, reporting zeroes. Each
|
|
17
|
+
* stage carries the reason it sits where it does.
|
|
18
|
+
*
|
|
19
|
+
* A purge is NOT serialized against work still starting on the analysis. The
|
|
20
|
+
* mapping from an analysis to its workflows is read once, up front, out of
|
|
21
|
+
* `cortex_runs`; a run inserted after that read is outside the captured set, and
|
|
22
|
+
* the `cortex_runs` delete later in the same purge removes the only row that could
|
|
23
|
+
* ever have named it. Its workflow row and every byte cascading off it then belong
|
|
24
|
+
* to no analysis — no retry reaches them, because nothing is left that attributes
|
|
25
|
+
* them to one. Quiesce the analysis before purging it: no new runs, no new
|
|
26
|
+
* data-profile triggers. This module cannot observe a host's in-flight work, so it
|
|
27
|
+
* does not enforce that.
|
|
28
|
+
*
|
|
29
|
+
* What it does not reach, so that absent coverage is never read as delivered:
|
|
30
|
+
* scheduled operational workflows (they belong to no analysis and accumulate
|
|
31
|
+
* independently of any purge), target assessments and their annotations (a
|
|
32
|
+
* separate top-level entity), the shared regulatory corpus, `messages` rows whose
|
|
33
|
+
* thread row is already gone (nothing attributes them to an analysis), and
|
|
34
|
+
* workspace files on disk (the embedder owns their disposal).
|
|
35
|
+
*/
|
|
36
|
+
import { errAsync, okAsync } from "neverthrow";
|
|
37
|
+
import { createNoopLogger } from "../lib/console-logger.js";
|
|
38
|
+
import { tryMutation, tryQuery, withTransaction } from "../lib/db-result.js";
|
|
39
|
+
import { searchIndexName } from "../workspace/search-config.js";
|
|
40
|
+
import { isSqlIdentifier } from "./vector-store.js";
|
|
41
|
+
/**
|
|
42
|
+
* The shape an `analysisId` must take to be purgeable. It is load-bearing twice
|
|
43
|
+
* over, and neither job survives the other being dropped:
|
|
44
|
+
*
|
|
45
|
+
* - Safe DDL interpolation. The analysis's vector-table name is derived from the
|
|
46
|
+
* id and interpolated into `DROP TABLE`, because an identifier cannot be a bind
|
|
47
|
+
* parameter. Confining the id to `[a-z0-9_-]` is what makes that derived name an
|
|
48
|
+
* identifier at all.
|
|
49
|
+
* - An unambiguous `dataprofile:` namespace. Profile workflows are found by the id
|
|
50
|
+
* prefix `dataprofile:{analysisId}:`, matched with `starts_with` and no
|
|
51
|
+
* delimiter check. An id holding a `:` makes analysis `a`'s prefix equally a
|
|
52
|
+
* prefix of every workflow id of an analysis named `a:x` — and each id that
|
|
53
|
+
* prefix returns is cancelled, then deleted with its descendants, taking another
|
|
54
|
+
* analysis's ledger rows and everything cascading off them. Excluding `:` is the
|
|
55
|
+
* only thing that keeps a purge inside the analysis it was asked for.
|
|
56
|
+
*
|
|
57
|
+
* Empty is outside the shape too: it derives the bare table prefix, which is a
|
|
58
|
+
* perfectly legal identifier and belongs to no analysis.
|
|
59
|
+
*/
|
|
60
|
+
const PURGEABLE_ANALYSIS_ID = /^[a-z0-9_-]+$/;
|
|
61
|
+
/**
|
|
62
|
+
* The analysis-keyed deletes that follow the thread/message pair, in the order
|
|
63
|
+
* they run.
|
|
64
|
+
*
|
|
65
|
+
* `cortex_plans` carries its own statement, immediately ahead of the
|
|
66
|
+
* `cortex_analysis_state` row it hangs off. The foreign key between the two
|
|
67
|
+
* cascades, so on a schema that has the key the statement is redundant — but the
|
|
68
|
+
* table is provisioned with `CREATE TABLE IF NOT EXISTS`, which adds no
|
|
69
|
+
* constraint to a table that already exists, so a database whose `cortex_plans`
|
|
70
|
+
* predates the key never acquires one. Resting on the cascade alone would make
|
|
71
|
+
* the completeness of a purge contingent on a constraint being present, and would
|
|
72
|
+
* silently leave the plans behind wherever it is not. The statement removes the
|
|
73
|
+
* rows either way; the cascade stays a backstop behind it.
|
|
74
|
+
*/
|
|
75
|
+
const ANALYSIS_KEYED_DELETES = [
|
|
76
|
+
{ op: "purgeAnalysis.artifacts", sql: "DELETE FROM cortex_artifacts WHERE analysis_id = $1" },
|
|
77
|
+
{ op: "purgeAnalysis.stepExecutions", sql: "DELETE FROM cortex_step_executions WHERE analysis_id = $1" },
|
|
78
|
+
{ op: "purgeAnalysis.runs", sql: "DELETE FROM cortex_runs WHERE analysis_id = $1" },
|
|
79
|
+
{ op: "purgeAnalysis.workingMemory", sql: "DELETE FROM cortex_working_memory WHERE analysis_id = $1" },
|
|
80
|
+
{ op: "purgeAnalysis.asks", sql: "DELETE FROM cortex_asks WHERE analysis_id = $1" },
|
|
81
|
+
{ op: "purgeAnalysis.askGrants", sql: "DELETE FROM cortex_ask_grants WHERE analysis_id = $1" },
|
|
82
|
+
{ op: "purgeAnalysis.plans", sql: "DELETE FROM cortex_plans WHERE analysis_id = $1" },
|
|
83
|
+
{ op: "purgeAnalysis.analysisState", sql: "DELETE FROM cortex_analysis_state WHERE analysis_id = $1" },
|
|
84
|
+
];
|
|
85
|
+
/**
|
|
86
|
+
* A refusal raised before any stage runs, so nothing is cancelled, no workflow row
|
|
87
|
+
* and no `cortex_*` row is gone, and a retry answers the same way at the same cost.
|
|
88
|
+
* `op` names the check that raised it.
|
|
89
|
+
*/
|
|
90
|
+
function refuse(op, message) {
|
|
91
|
+
return errAsync({ type: "mutation_failed", op, cause: new Error(message) });
|
|
92
|
+
}
|
|
93
|
+
/** Run one delete on the transaction's client and report how many rows it took. */
|
|
94
|
+
function deleteRows(client, op, text, analysisId) {
|
|
95
|
+
return tryMutation(op, async () => {
|
|
96
|
+
const { rowCount } = await client.query({ text, values: [analysisId] });
|
|
97
|
+
return rowCount ?? 0;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
export function createAnalysisPurge({ pool, workflows, logger: injected }) {
|
|
101
|
+
const logger = (injected ?? createNoopLogger()).named("purge-analysis");
|
|
102
|
+
/**
|
|
103
|
+
* Every workflow id the analysis can still be traced to, read while the rows
|
|
104
|
+
* that record them are all still on disk. `cortex_runs.run_id` IS the parent
|
|
105
|
+
* workflow id, and those rows together with the `dataprofile:{analysisId}:` id
|
|
106
|
+
* namespace are the ONLY mapping from an analysis to its workflows: a delete
|
|
107
|
+
* of `cortex_runs` that lands before this read completes strands the largest
|
|
108
|
+
* part of the footprint permanently out of reach.
|
|
109
|
+
*/
|
|
110
|
+
const collectWorkflowIds = (analysisId) => tryQuery("purgeAnalysis.runWorkflowIds", async () => {
|
|
111
|
+
const { rows } = await pool.query({
|
|
112
|
+
text: "SELECT run_id FROM cortex_runs WHERE analysis_id = $1",
|
|
113
|
+
values: [analysisId],
|
|
114
|
+
});
|
|
115
|
+
return rows.map((row) => row.run_id);
|
|
116
|
+
}).andThen((runIds) => workflows.findByIdPrefix(`dataprofile:${analysisId}:`).map((profileIds) => {
|
|
117
|
+
logger.debug("collected workflow identity", { analysisId, runs: runIds.length, dataProfiles: profileIds.length });
|
|
118
|
+
// The two id sources cannot overlap, but collapsing duplicates keeps a
|
|
119
|
+
// repeated id out of the reclaimed count regardless.
|
|
120
|
+
return [...new Set([...runIds, ...profileIds])];
|
|
121
|
+
}));
|
|
122
|
+
/**
|
|
123
|
+
* One transaction for the whole `cortex_*` stage, so a mid-stage failure leaves
|
|
124
|
+
* no half-deleted analysis behind.
|
|
125
|
+
*/
|
|
126
|
+
const deleteCortexRows = (analysisId) => withTransaction(pool, "purgeAnalysis.cortexRows", (client) =>
|
|
127
|
+
// `messages` has no `analysis_id` column and no foreign key: the only
|
|
128
|
+
// route from an analysis to its messages is a join through its thread
|
|
129
|
+
// rows. So the messages go first and the thread rows that name them go
|
|
130
|
+
// second — reversed, the messages become unattributable and unreachable
|
|
131
|
+
// by any later reclamation. No `deleted_at` filter on either: an archived
|
|
132
|
+
// thread is still the analysis's, and a purge takes it too.
|
|
133
|
+
deleteRows(client, "purgeAnalysis.messages", `DELETE FROM messages
|
|
134
|
+
WHERE thread_id IN (SELECT thread_id FROM cortex_analysis_threads WHERE analysis_id = $1)`, analysisId)
|
|
135
|
+
.andThen((messages) => deleteRows(client, "purgeAnalysis.threads", "DELETE FROM cortex_analysis_threads WHERE analysis_id = $1", analysisId).map((threads) => ({
|
|
136
|
+
threads,
|
|
137
|
+
messages,
|
|
138
|
+
})))
|
|
139
|
+
// Folded so the first failure short-circuits the rest. Submitting them
|
|
140
|
+
// together would not: the transaction is aborted the moment one raises,
|
|
141
|
+
// every statement behind it comes back `25P02`, and the `op` naming the
|
|
142
|
+
// delete that actually broke is buried under theirs.
|
|
143
|
+
.andThen((counts) => ANALYSIS_KEYED_DELETES.reduce((stage, { op, sql }) => stage.andThen(() => deleteRows(client, op, sql, analysisId).map(() => undefined)), okAsync(undefined)).map(() => counts)));
|
|
144
|
+
/**
|
|
145
|
+
* `vectorIndexDropped` is established by a `to_regclass` probe taken
|
|
146
|
+
* immediately before the drop, because `DROP TABLE IF EXISTS` reports nothing
|
|
147
|
+
* about whether it found anything. The probe resolves the name exactly as the
|
|
148
|
+
* drop does — both go through `search_path`, and the probe is handed the same
|
|
149
|
+
* double-quoted form the drop interpolates, since `to_regclass` parses its
|
|
150
|
+
* argument as an SQL identifier and would fold an unquoted one to lower case.
|
|
151
|
+
* The drop runs regardless of what the probe saw, so the window between them can
|
|
152
|
+
* at worst misreport the flag for an analysis already being destroyed, never
|
|
153
|
+
* leave a table behind.
|
|
154
|
+
*
|
|
155
|
+
* `indexName` arrives already accepted at the entry point; that is the whole
|
|
156
|
+
* warrant for interpolating it into the DDL below.
|
|
157
|
+
*/
|
|
158
|
+
const dropVectorIndex = (indexName) => tryQuery("purgeAnalysis.vectorIndexPresent", async () => {
|
|
159
|
+
const { rows } = await pool.query({
|
|
160
|
+
text: "SELECT to_regclass($1::text) IS NOT NULL AS present",
|
|
161
|
+
values: [`"${indexName}"`],
|
|
162
|
+
});
|
|
163
|
+
return rows[0]?.present ?? false;
|
|
164
|
+
}).andThen((present) => tryMutation("purgeAnalysis.dropVectorIndex", async () => {
|
|
165
|
+
await pool.query(`DROP TABLE IF EXISTS "${indexName}"`);
|
|
166
|
+
}).map(() => present));
|
|
167
|
+
const purgeAnalysis = (analysisId) => {
|
|
168
|
+
if (!PURGEABLE_ANALYSIS_ID.test(analysisId)) {
|
|
169
|
+
return refuse("purgeAnalysis.analysisId", `purge-analysis: refusing unpurgeable analysis id "${analysisId}"`);
|
|
170
|
+
}
|
|
171
|
+
const indexName = searchIndexName(analysisId);
|
|
172
|
+
// The id's shape already settles the derived name's characters; what it cannot
|
|
173
|
+
// settle is how long the derivation makes it. This is the same guard the vector
|
|
174
|
+
// store applies to the same name on the write side, so the accepted shape
|
|
175
|
+
// cannot drift between the two, and a name Postgres would silently truncate
|
|
176
|
+
// onto another analysis's table is refused rather than dropped.
|
|
177
|
+
if (!isSqlIdentifier(indexName)) {
|
|
178
|
+
return refuse("purgeAnalysis.vectorIndexName", `purge-analysis: unsafe vector index name "${indexName}"`);
|
|
179
|
+
}
|
|
180
|
+
return (collectWorkflowIds(analysisId)
|
|
181
|
+
// Cancel before delete. Removing the status row of a running workflow
|
|
182
|
+
// does not stop the executor running it, and that executor goes on
|
|
183
|
+
// writing rows behind the delete — re-materializing exactly the orphans
|
|
184
|
+
// the purge exists to remove. A failed cancel leaves that risk in place,
|
|
185
|
+
// so it ends the purge rather than being absorbed into a completeness
|
|
186
|
+
// claim the operation can no longer make.
|
|
187
|
+
.andThen((ids) => workflows.cancel(ids).map(() => ids))
|
|
188
|
+
// Workflow rows go before the `cortex_*` ledger that names them: a failure
|
|
189
|
+
// here leaves the id mapping on disk and a retry finds it, whereas the
|
|
190
|
+
// reverse order turns one transient failure into a permanent orphan.
|
|
191
|
+
// Descendants are included because a run's child step workflows carry the
|
|
192
|
+
// sandbox transcripts and are reachable only through their parent.
|
|
193
|
+
.andThen((ids) => workflows.deleteWorkflows(ids, true))
|
|
194
|
+
.andThen((workflowsTargeted) => deleteCortexRows(analysisId).map((counts) => ({ ...counts, workflows: workflowsTargeted })))
|
|
195
|
+
// The vector table is dropped after the transaction rather than inside
|
|
196
|
+
// it: `DROP TABLE` takes an ACCESS EXCLUSIVE lock, and enlisting it would
|
|
197
|
+
// hold that lock for the duration of every row delete above.
|
|
198
|
+
.andThen((reclaimed) => dropVectorIndex(indexName).map((vectorIndexDropped) => ({ ...reclaimed, vectorIndexDropped })))
|
|
199
|
+
.map((outcome) => {
|
|
200
|
+
logger.info("purged analysis", { analysisId, ...outcome });
|
|
201
|
+
return outcome;
|
|
202
|
+
}));
|
|
203
|
+
};
|
|
204
|
+
return { purgeAnalysis };
|
|
205
|
+
}
|
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
import { ResultAsync } from "neverthrow";
|
|
16
16
|
import type { Pool } from "pg";
|
|
17
17
|
import { type DbError } from "../lib/db-result.js";
|
|
18
|
+
/**
|
|
19
|
+
* Whether `name` may be interpolated into a statement as an identifier. Shared
|
|
20
|
+
* with the reclaim side, which guards the same derived names against the same
|
|
21
|
+
* shape — one invariant, one place to read it.
|
|
22
|
+
*/
|
|
23
|
+
export declare function isSqlIdentifier(name: string): boolean;
|
|
18
24
|
export interface CreateIndexInput {
|
|
19
25
|
readonly indexName: string;
|
|
20
26
|
readonly dimension: number;
|
|
@@ -14,9 +14,29 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { okAsync } from "neverthrow";
|
|
16
16
|
import { tryMutation, tryQuery } from "../lib/db-result.js";
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The identifier shape and length the harness may interpolate into a statement.
|
|
19
|
+
* An identifier cannot be a bind parameter, so a name reaching SQL has to be
|
|
20
|
+
* checked rather than bound, and lower-snake is the whole of what the harness ever
|
|
21
|
+
* derives. The bound is as load-bearing as the shape: Postgres keeps only the
|
|
22
|
+
* first 63 bytes of an identifier and truncates the rest silently, so two longer
|
|
23
|
+
* names sharing a 63-byte prefix resolve to one table — a write that lands in
|
|
24
|
+
* another resource's index, and a drop that takes it. The shape is ASCII-only, so
|
|
25
|
+
* a name that matches it has one byte per character and `length` is the byte count
|
|
26
|
+
* the limit is measured in.
|
|
27
|
+
*/
|
|
28
|
+
const SQL_IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
|
|
29
|
+
const MAX_SQL_IDENTIFIER_BYTES = 63;
|
|
30
|
+
/**
|
|
31
|
+
* Whether `name` may be interpolated into a statement as an identifier. Shared
|
|
32
|
+
* with the reclaim side, which guards the same derived names against the same
|
|
33
|
+
* shape — one invariant, one place to read it.
|
|
34
|
+
*/
|
|
35
|
+
export function isSqlIdentifier(name) {
|
|
36
|
+
return SQL_IDENTIFIER.test(name) && name.length <= MAX_SQL_IDENTIFIER_BYTES;
|
|
37
|
+
}
|
|
18
38
|
function assertIdent(name) {
|
|
19
|
-
if (!
|
|
39
|
+
if (!isSqlIdentifier(name)) {
|
|
20
40
|
throw new Error(`vector-store: unsafe index name "${name}"`);
|
|
21
41
|
}
|
|
22
42
|
}
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* no terminal at all.
|
|
21
21
|
*/
|
|
22
22
|
import type { Logger } from "../lib/logger.js";
|
|
23
|
+
import type { ToolDetailResolver } from "../tools/detail-resolver.js";
|
|
23
24
|
/**
|
|
24
25
|
* Writes one part to the run-event stream. Injected rather than imported so this
|
|
25
26
|
* module stays free of the durability engine and so a test can record what a
|
|
@@ -51,8 +52,19 @@ export interface ProfileActivityEmitter {
|
|
|
51
52
|
sandboxInit(): Promise<void>;
|
|
52
53
|
/** Reported once the sandbox is ready, covering the gap before the agent's first tool call. */
|
|
53
54
|
agentStarting(): Promise<void>;
|
|
54
|
-
/**
|
|
55
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Reported per tool call the profiler agent makes, phrased by the shared
|
|
57
|
+
* translator.
|
|
58
|
+
*
|
|
59
|
+
* `resolveDetail` is built over the profiler agent's own tool list, so the
|
|
60
|
+
* phrase comes from the called tool's `describeCall` hook. It is passed per
|
|
61
|
+
* call rather than held by the emitter because the emitter exists before the
|
|
62
|
+
* agent does — `sandboxInit` is reported before the sandbox the agent's tools
|
|
63
|
+
* are bound to. The call site supplies a RESOLVER, never a phrase: the
|
|
64
|
+
* phrases are this capability's observable contract, and a call site that
|
|
65
|
+
* composed its own is a call site that can drift from it.
|
|
66
|
+
*/
|
|
67
|
+
forTool(name: string, input: unknown, resolveDetail?: ToolDetailResolver): Promise<void>;
|
|
56
68
|
/** Reported for the vector-store pass that indexes per-file descriptions. */
|
|
57
69
|
indexing(): Promise<void>;
|
|
58
70
|
/** The success terminal. Emitted only after the terminal ledger write has landed. */
|
|
@@ -48,7 +48,7 @@ export function createProfileActivityEmitter(write, frame, logger) {
|
|
|
48
48
|
return {
|
|
49
49
|
sandboxInit: () => emit("sandbox-init", "Starting sandbox"),
|
|
50
50
|
agentStarting: () => emit("executing", "Running data-profiler"),
|
|
51
|
-
forTool: (name, input) => emit("executing", activityForTool(name, input)),
|
|
51
|
+
forTool: (name, input, resolveDetail) => emit("executing", activityForTool(name, input, resolveDetail)),
|
|
52
52
|
indexing: () => emit("indexing", "Indexing input descriptions for search"),
|
|
53
53
|
complete: () => emit("complete", "Profile complete"),
|
|
54
54
|
failed: (reason) => emit("failed", reason),
|
|
@@ -23,6 +23,7 @@ import type { RunAuthorization, RunAuthorizer } from "../execution/run-authorize
|
|
|
23
23
|
import type { StagedInput } from "../execution/staged-input.js";
|
|
24
24
|
import type { BioToolKeys } from "../tools/bio/keys.js";
|
|
25
25
|
import type { Logger } from "../lib/logger.js";
|
|
26
|
+
import type { UsageRecorder } from "../billing/usage-recorder.js";
|
|
26
27
|
import type { ChatProvider, EmbeddingProvider } from "../providers/types.js";
|
|
27
28
|
import type { SandboxClient } from "../sandbox/client.js";
|
|
28
29
|
import type { WorkspaceFilesystem } from "../workspace/filesystem.js";
|
|
@@ -53,6 +54,8 @@ export interface DataProfileDeps extends EnvironmentStorePaths {
|
|
|
53
54
|
readonly embedding: EmbeddingProvider;
|
|
54
55
|
/** Absolute path to the skills tree (one subdirectory per skill). */
|
|
55
56
|
readonly skillsDir: string;
|
|
57
|
+
/** LLM usage-accounting seam for the profiler agent loop; omitted falls back to the no-op recorder. */
|
|
58
|
+
readonly usageRecorder?: UsageRecorder;
|
|
56
59
|
}
|
|
57
60
|
/**
|
|
58
61
|
* Workflow input. JSON-serialisable — DBOS persists it as the workflow's
|
|
@@ -26,6 +26,7 @@ import { durableStep } from "../loop/run-step.js";
|
|
|
26
26
|
import { createNoopLogger } from "../lib/console-logger.js";
|
|
27
27
|
import { unwrapOrThrow } from "../lib/result.js";
|
|
28
28
|
import { defineTool } from "../tools/define-tool.js";
|
|
29
|
+
import { createDetailResolver } from "../tools/detail-resolver.js";
|
|
29
30
|
import { renderWorkspace } from "../prompts/briefing.js";
|
|
30
31
|
import { toSandboxPath } from "../workspace/paths.js";
|
|
31
32
|
import { estimateDataProfileResources } from "../sandbox/estimate-data-profile-resources.js";
|
|
@@ -36,8 +37,9 @@ import { ProfilerOutputSchema } from "../schemas/data-profile-schemas.js";
|
|
|
36
37
|
import { completeDataProfile, failDataProfile, loadDataProfileStatus, loadSeedInputFileIds, recordDataProfileWorkflowId, tryRerunDataProfile, tryStartDataProfile, upsertArtifacts, } from "../state/index.js";
|
|
37
38
|
import { createProfileActivityEmitter } from "./data-profile-activity.js";
|
|
38
39
|
import { ensureSearchIndex, searchIndexName } from "../workspace/search-config.js";
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
// The run literal is declared in `contracts/` — a consumer reads it back off recorded usage, and this
|
|
41
|
+
// module is unimportable to one that only wants the string (see the constant's own doc).
|
|
42
|
+
import { DATA_PROFILE_RUN_LITERAL } from "../contracts/data-profile.js";
|
|
41
43
|
const DATA_PROFILE_STEP_LITERAL = "profile";
|
|
42
44
|
const DATA_PROFILE_AGENT_ID = "data-profiler";
|
|
43
45
|
/** Sandbox-server exec budget for the profile run. */
|
|
@@ -277,6 +279,9 @@ export async function runDataProfileBody(input, deps) {
|
|
|
277
279
|
});
|
|
278
280
|
const baseAgent = createDataProfilerAgent(sandboxAgentDeps);
|
|
279
281
|
const agentDef = { ...baseAgent, tools: [...baseAgent.tools, submitProfileTool] };
|
|
282
|
+
// The profiler's own roster, `submit_profile` included, so every call it
|
|
283
|
+
// makes is phrased by the tool that was called.
|
|
284
|
+
const resolveDetail = createDetailResolver(agentDef.tools, logger);
|
|
280
285
|
const signal = new AbortController().signal;
|
|
281
286
|
// Covers the gap between a ready sandbox and the agent's first tool call, which would
|
|
282
287
|
// otherwise still read as `Starting sandbox` long after the container was up. The run
|
|
@@ -294,10 +299,11 @@ export async function runDataProfileBody(input, deps) {
|
|
|
294
299
|
// counter and desynchronise the recorded sequence on replay.
|
|
295
300
|
emit: async (event) => {
|
|
296
301
|
if (event.type === "tool-started")
|
|
297
|
-
await activity.forTool(event.name, event.input);
|
|
302
|
+
await activity.forTool(event.name, event.input, resolveDetail);
|
|
298
303
|
},
|
|
299
304
|
runStep: durableStep,
|
|
300
305
|
resolved: () => capturedProfile !== null,
|
|
306
|
+
usageRecorder: deps.usageRecorder,
|
|
301
307
|
}, {
|
|
302
308
|
tools: [submitProfileTool],
|
|
303
309
|
nudge: "You stopped without calling submit_profile, so no profile was " +
|
package/dist/tools/bio/pubmed.js
CHANGED
|
@@ -125,6 +125,21 @@ export function createPubMedTool(deps) {
|
|
|
125
125
|
"`outline` from the first call and re-request the specific `sections` you need rather than raising maxChars. " +
|
|
126
126
|
"available: false is an expected outcome, not an error — do not retry it.",
|
|
127
127
|
inputSchema,
|
|
128
|
+
// One tool, three jobs. The action alone would still render three
|
|
129
|
+
// different searches identically, so each action names its own subject:
|
|
130
|
+
// the query, how many articles, or which article.
|
|
131
|
+
describeCall: (input) => {
|
|
132
|
+
switch (input.action) {
|
|
133
|
+
case "search":
|
|
134
|
+
return `search ${input.query ?? ""}`;
|
|
135
|
+
case "details": {
|
|
136
|
+
const count = input.pmids?.length ?? 0;
|
|
137
|
+
return `details for ${count} ${count === 1 ? "article" : "articles"}`;
|
|
138
|
+
}
|
|
139
|
+
case "fulltext":
|
|
140
|
+
return `fulltext ${input.pmcId ?? ""}`;
|
|
141
|
+
}
|
|
142
|
+
},
|
|
128
143
|
execute: async (input) => {
|
|
129
144
|
switch (input.action) {
|
|
130
145
|
case "search":
|
|
@@ -5,6 +5,12 @@ import { ok } from "neverthrow";
|
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { defineTool } from "../define-tool.js";
|
|
7
7
|
import { lookupGenes } from "../lib/ensembl-client.js";
|
|
8
|
+
/**
|
|
9
|
+
* How many symbols a detail names before it switches to a count plus a sample.
|
|
10
|
+
* Eight symbols of typical length stay well inside the emit-site cap, so the
|
|
11
|
+
* listed form is never the thing that trips it.
|
|
12
|
+
*/
|
|
13
|
+
const SYMBOLS_LISTED_IN_FULL = 8;
|
|
8
14
|
export const searchGeneTool = defineTool({
|
|
9
15
|
id: "search_gene",
|
|
10
16
|
description: "Resolve gene symbols against Ensembl — the identifier step other tools depend on. Per symbol: Ensembl gene ID (ENSG…), coordinates, strand, " +
|
|
@@ -14,5 +20,11 @@ export const searchGeneTool = defineTool({
|
|
|
14
20
|
symbols: z.array(z.string()).min(1).max(200).describe("Gene symbols to look up (e.g. ['BRCA1', 'TP53'])"),
|
|
15
21
|
species: z.string().default("homo_sapiens").describe("Species name (e.g. 'homo_sapiens', 'mus_musculus')"),
|
|
16
22
|
}),
|
|
23
|
+
// The symbols are the call. A large batch is summarized here rather than
|
|
24
|
+
// left to the emit-site cap: that cap cuts at a character, so a 200-symbol
|
|
25
|
+
// join arrives ending in `TP5` — a fragment that reads as a real symbol and
|
|
26
|
+
// says nothing about how many were dropped. The count leads, so a batch is
|
|
27
|
+
// legible as a batch even when the sample after it is itself trimmed.
|
|
28
|
+
describeCall: ({ symbols }) => symbols.length <= SYMBOLS_LISTED_IN_FULL ? symbols.join(", ") : `${symbols.length} genes: ${symbols.slice(0, SYMBOLS_LISTED_IN_FULL).join(", ")}, …`,
|
|
17
29
|
execute: async ({ symbols, species }) => ok(await lookupGenes(symbols, { species })),
|
|
18
30
|
});
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* captures its deps and calls `defineTool`.
|
|
9
9
|
*
|
|
10
10
|
* `ToolContext` carries only request-scoped values every tool may need
|
|
11
|
-
* (`invocationId`, `session`, `signal`, `emit`, `runStep`, `ask`) — no pool, no sandbox, no
|
|
11
|
+
* (`invocationId`, `session`, `signal`, `emit`, `runStep`, `ask`, `turnUsage`) — no pool, no sandbox, no
|
|
12
12
|
* logger. The error contract: an expected
|
|
13
13
|
* outcome ("not found", "no results") stays in the ok channel as a data
|
|
14
14
|
* variant (`ok({ found: false })`); an unexpected failure is an `err(ToolError)`
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import type { Result } from "neverthrow";
|
|
19
19
|
import { z } from "zod";
|
|
20
20
|
import type { AgentSession } from "../auth/types.js";
|
|
21
|
+
import type { AgentRunUsage } from "../loop/metrics.js";
|
|
21
22
|
import type { EmitFn, RunStep } from "../loop/types.js";
|
|
22
23
|
import type { AskApproval, AskRequest } from "./approval/contract.js";
|
|
23
24
|
export type { EmitFn };
|
|
@@ -42,7 +43,8 @@ export declare function isToolError(value: unknown): value is ToolError;
|
|
|
42
43
|
* dependencies (see the harness-durable-runtime spec) — invocation identity,
|
|
43
44
|
* `session`, `signal`, `emit`, the `runStep`
|
|
44
45
|
* durability seam (`passthroughStep` in chat, `DBOS.runStep` in workflows) a
|
|
45
|
-
* tool uses to wrap its own durable work,
|
|
46
|
+
* tool uses to wrap its own durable work, the `ask` user-approval seam, and the
|
|
47
|
+
* turn's usage accumulator a sub-agent-running tool hands to its child loop.
|
|
46
48
|
*/
|
|
47
49
|
export interface ToolContext {
|
|
48
50
|
/** Stable identity of this AI SDK tool call. Redelivery preserves it; a new
|
|
@@ -64,10 +66,18 @@ export interface ToolContext {
|
|
|
64
66
|
* waiting on a surface that cannot answer.
|
|
65
67
|
*/
|
|
66
68
|
readonly ask: (request: AskRequest) => Promise<AskApproval>;
|
|
69
|
+
/**
|
|
70
|
+
* The turn's usage accumulator — the mutable total every loop under this
|
|
71
|
+
* turn folds its LLM calls into. A tool that drives a child `runAgent`
|
|
72
|
+
* passes it straight into that run's options, which is what puts the
|
|
73
|
+
* child's tokens in the turn total its root loop reports. A tool that runs
|
|
74
|
+
* no child agent ignores it.
|
|
75
|
+
*/
|
|
76
|
+
readonly turnUsage?: AgentRunUsage;
|
|
67
77
|
}
|
|
68
78
|
/**
|
|
69
79
|
* A packaged tool: identity, the Zod input contract, the emitted AI SDK input
|
|
70
|
-
* schema, execution mode, and the executor.
|
|
80
|
+
* schema, execution mode, the optional call-description hook, and the executor.
|
|
71
81
|
*/
|
|
72
82
|
export interface Tool<Input = unknown, Output = unknown> {
|
|
73
83
|
readonly id: string;
|
|
@@ -75,6 +85,8 @@ export interface Tool<Input = unknown, Output = unknown> {
|
|
|
75
85
|
readonly inputSchema: z.ZodType;
|
|
76
86
|
readonly jsonSchema: Record<string, unknown>;
|
|
77
87
|
readonly executionMode: ToolExecutionMode;
|
|
88
|
+
/** See {@link ToolDefinition.describeCall}. Absent when the tool declares none. */
|
|
89
|
+
describeCall?(input: Input): string;
|
|
78
90
|
execute(input: Input, ctx: ToolContext): Promise<Result<Output, ToolError>>;
|
|
79
91
|
}
|
|
80
92
|
export interface ToolDefinition<Schema extends z.ZodType, Output> {
|
|
@@ -82,6 +94,32 @@ export interface ToolDefinition<Schema extends z.ZodType, Output> {
|
|
|
82
94
|
readonly description: string;
|
|
83
95
|
readonly inputSchema: Schema;
|
|
84
96
|
readonly executionMode?: ToolExecutionMode;
|
|
97
|
+
/**
|
|
98
|
+
* One line naming what THIS call is doing — the call-time counterpart of
|
|
99
|
+
* `description`. `description` self-describes the tool at attach time, so an
|
|
100
|
+
* agent knows what it holds; `describeCall` self-describes one invocation, so
|
|
101
|
+
* a surface can render four `update_working_memory` calls as four distinct
|
|
102
|
+
* lines instead of four identical chips.
|
|
103
|
+
*
|
|
104
|
+
* It lives here, beside `inputSchema`, because that is the one place the
|
|
105
|
+
* compiler checks the two against each other. A host-side formatter reading
|
|
106
|
+
* `input.path` by string key breaks silently the day the schema moves; this
|
|
107
|
+
* one fails to build. The tool author writes it because the tool author knows
|
|
108
|
+
* which field matters.
|
|
109
|
+
*
|
|
110
|
+
* Synchronous and pure — no I/O, no ambient state. Dispatch is what the user
|
|
111
|
+
* waits on, and a description must never be able to fail a call: the loop
|
|
112
|
+
* validates the input against `inputSchema` first, guards the call, and drops
|
|
113
|
+
* the detail on any failure (see the tool-call-detail capability).
|
|
114
|
+
*
|
|
115
|
+
* Return whatever reads best. Normalization — one line, control characters
|
|
116
|
+
* removed, secrets redacted, length capped — happens once at the emit site,
|
|
117
|
+
* never here.
|
|
118
|
+
*
|
|
119
|
+
* OPTIONAL, and absence is a normal state: a tool without it is observable
|
|
120
|
+
* exactly as it is today. The hook never reaches the model.
|
|
121
|
+
*/
|
|
122
|
+
describeCall?(input: z.infer<Schema>): string;
|
|
85
123
|
execute(input: z.infer<Schema>, ctx: ToolContext): Promise<Result<Output, ToolError>>;
|
|
86
124
|
}
|
|
87
125
|
/**
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* captures its deps and calls `defineTool`.
|
|
9
9
|
*
|
|
10
10
|
* `ToolContext` carries only request-scoped values every tool may need
|
|
11
|
-
* (`invocationId`, `session`, `signal`, `emit`, `runStep`, `ask`) — no pool, no sandbox, no
|
|
11
|
+
* (`invocationId`, `session`, `signal`, `emit`, `runStep`, `ask`, `turnUsage`) — no pool, no sandbox, no
|
|
12
12
|
* logger. The error contract: an expected
|
|
13
13
|
* outcome ("not found", "no results") stays in the ok channel as a data
|
|
14
14
|
* variant (`ok({ found: false })`); an unexpected failure is an `err(ToolError)`
|
|
@@ -44,6 +44,9 @@ export function defineTool(def) {
|
|
|
44
44
|
inputSchema: def.inputSchema,
|
|
45
45
|
jsonSchema,
|
|
46
46
|
executionMode,
|
|
47
|
+
// Spread rather than assigned: a tool that declares no hook carries no
|
|
48
|
+
// `describeCall` key at all, so "has a hook" is one property check.
|
|
49
|
+
...(def.describeCall === undefined ? {} : { describeCall: def.describeCall }),
|
|
47
50
|
execute: def.execute,
|
|
48
51
|
};
|
|
49
52
|
}
|