@inflexa-ai/harness 0.5.0 → 0.6.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.
@@ -34,6 +34,7 @@ import type { ResourcePolicy } from "../config/resource-limits.js";
34
34
  import type { ChromeConfig } from "../lib/chrome.js";
35
35
  import type { AgentDefinition } from "../loop/types.js";
36
36
  import type { ChatProvider, EmbeddingProvider } from "../providers/types.js";
37
+ import type { Tool } from "../tools/define-tool.js";
37
38
  import type { WorkspaceFilesystem } from "../workspace/filesystem.js";
38
39
  import type { ResolveWorkspaceRoot } from "../workspace/paths.js";
39
40
  import { type BioToolKeys } from "../tools/bio/keys.js";
@@ -104,6 +105,13 @@ export interface ConversationAgentDeps {
104
105
  * planning guidance and scheduling keep their legacy behavior.
105
106
  */
106
107
  readonly resourcePolicy?: ResourcePolicy;
108
+ /**
109
+ * Embedder-contributed conversation-tool seam — additional tools the host
110
+ * supplies, appended after the built-in roster. The harness stays agnostic to
111
+ * what they do; each receives the same `ToolContext` (including `ask`) as any
112
+ * built-in tool.
113
+ */
114
+ readonly hostTools?: readonly Tool[];
107
115
  }
108
116
  /** Build the conversation `AgentDefinition` with every tool fully dep-bound. */
109
117
  export declare function createConversationAgent(deps: ConversationAgentDeps): AgentDefinition;
@@ -48,7 +48,7 @@ export const CONVERSATION_AGENT_ID = "conversation-agent";
48
48
  const CONVERSATION_MAX_ITERATIONS = 50;
49
49
  /** Build the conversation `AgentDefinition` with every tool fully dep-bound. */
50
50
  export function createConversationAgent(deps) {
51
- const { provider, pool, embedding, workspaceFs, model, executeAnalysisWorkflow, ephemeralWorkflow, resolveWorkspaceRoot, runAuthorizer, runLauncher, createPreviewPublisher, bioKeys, templatesDir, skillsDir, chrome, resourcePolicy, } = deps;
51
+ const { provider, pool, embedding, workspaceFs, model, executeAnalysisWorkflow, ephemeralWorkflow, resolveWorkspaceRoot, runAuthorizer, runLauncher, createPreviewPublisher, bioKeys, templatesDir, skillsDir, chrome, resourcePolicy, hostTools, } = deps;
52
52
  const workingMemory = createWorkingMemory(pool);
53
53
  const ncbi = createNcbiTools(bioKeys);
54
54
  const chemDb = createChemDbTools(bioKeys);
@@ -126,6 +126,9 @@ export function createConversationAgent(deps) {
126
126
  createGrepTool(workspaceFs),
127
127
  // Working memory.
128
128
  createUpdateWorkingMemoryTool(workingMemory, pool),
129
+ // Host-contributed tools ride the same context and dispatch path as the
130
+ // built-ins; appended last so the built-in roster order stays fixed.
131
+ ...(hostTools ?? []),
129
132
  ];
130
133
  return {
131
134
  id: CONVERSATION_AGENT_ID,
@@ -260,6 +260,11 @@ CREATE TABLE IF NOT EXISTS cortex_asks (
260
260
  title TEXT NOT NULL,
261
261
  command TEXT NOT NULL,
262
262
  detail TEXT,
263
+ -- The key the standing grant behind an 'always' is recorded under: the displayed
264
+ -- command unless the tool supplied a broader grant key. Written on every insert,
265
+ -- defaulting to the command when absent. Nullable only so the additive migration
266
+ -- can add it to an already-created table without a per-row backfill.
267
+ grant_key TEXT,
263
268
  status TEXT NOT NULL,
264
269
  reply JSONB,
265
270
  created_at TEXT NOT NULL,
@@ -275,14 +280,15 @@ CREATE INDEX IF NOT EXISTS idx_cortex_asks_pending
275
280
  ON cortex_asks(status) WHERE status = 'pending';
276
281
 
277
282
  -- Standing approval grants behind an 'always' reply — keyed by the analysis and
278
- -- the exact command string the user approved, so a later matching ask
279
- -- auto-approves without pausing. Lives for the analysis lifecycle, surviving
280
- -- process restarts, and never applies to another analysis.
283
+ -- the ask's grant key (the displayed command when the tool supplied no broader
284
+ -- key), so a later matching ask auto-approves without pausing. Lives for the
285
+ -- analysis lifecycle, surviving process restarts, and never applies to another
286
+ -- analysis.
281
287
  CREATE TABLE IF NOT EXISTS cortex_ask_grants (
282
288
  analysis_id TEXT NOT NULL,
283
- command TEXT NOT NULL,
289
+ grant_key TEXT NOT NULL,
284
290
  created_at TEXT NOT NULL,
285
- PRIMARY KEY (analysis_id, command)
291
+ PRIMARY KEY (analysis_id, grant_key)
286
292
  );
287
293
  `;
288
294
  /**
@@ -406,6 +412,26 @@ export async function initCortexState(pool, injected) {
406
412
  // dropping an absent NOT NULL no-ops, so this is a no-op on fresh DBs.
407
413
  "ALTER TABLE cortex_analysis_state ALTER COLUMN data_profile_status DROP NOT NULL",
408
414
  "ALTER TABLE messages ADD COLUMN IF NOT EXISTS message_envelope JSONB",
415
+ // Grant key an 'always' records the standing grant under. Nullable
416
+ // and unbackfilled: every new row writes it (command when absent), and
417
+ // orphaned legacy pending rows are swept to 'expired' before any answer
418
+ // reads it, so no existing row is left needing a value.
419
+ "ALTER TABLE cortex_asks ADD COLUMN IF NOT EXISTS grant_key TEXT",
420
+ // The grants table's key column is named for what it stores. A table
421
+ // created when the column was `command` is renamed in place — its
422
+ // values carry over unchanged, because a grant recorded without a
423
+ // grant key is keyed on its exact command. Guarded via
424
+ // information_schema (Postgres has no RENAME COLUMN IF EXISTS) and
425
+ // scoped to current_schema() so parallel test schemas in the same
426
+ // database never trigger each other's rename.
427
+ `DO $$ BEGIN
428
+ IF EXISTS (
429
+ SELECT 1 FROM information_schema.columns
430
+ WHERE table_schema = current_schema() AND table_name = 'cortex_ask_grants' AND column_name = 'command'
431
+ ) THEN
432
+ ALTER TABLE cortex_ask_grants RENAME COLUMN command TO grant_key;
433
+ END IF;
434
+ END $$`,
409
435
  ];
410
436
  for (const sql of addMigrations) {
411
437
  await client.query(sql);
@@ -21,6 +21,14 @@ export interface AskRequest {
21
21
  readonly title: string;
22
22
  readonly command: string;
23
23
  readonly detail?: string;
24
+ /**
25
+ * Keys the standing grant an `always` records when the class being blessed is
26
+ * broader than the displayed `command` — a tool that grants a whole family of
27
+ * operations passes the family's key here while still showing one concrete
28
+ * command. It is a generic, opaque key string, not a tool- or domain-specific
29
+ * field, and the surface never renders it. Absent, the grant keys on `command`.
30
+ */
31
+ readonly grantKey?: string;
24
32
  }
25
33
  /**
26
34
  * A user's decision on an approval request — three distinct outcomes, never a
@@ -27,11 +27,13 @@ export function createAskGateway(deps) {
27
27
  title: request.title,
28
28
  command: request.command,
29
29
  detail: request.detail ?? null,
30
+ grantKey: request.grantKey ?? null,
30
31
  createdAt: now,
31
32
  };
32
- // A standing grant for this exact command short-circuits the prompt: record
33
- // the invocation as `resolved` for the audit trail and return without pausing.
34
- if (unwrapOrThrow(await selectGrant(pool, ctx.analysisId, request.command))) {
33
+ // A standing grant for this ask's grant key — the command unless the tool
34
+ // supplied a broader key — short-circuits the prompt: record the invocation as
35
+ // `resolved` for the audit trail and return without pausing.
36
+ if (unwrapOrThrow(await selectGrant(pool, ctx.analysisId, request.grantKey ?? request.command))) {
35
37
  unwrapOrThrow(await insertGrantedAsk(pool, row, now));
36
38
  return { kind: "always" };
37
39
  }
@@ -40,6 +40,8 @@ export interface AskRow {
40
40
  readonly title: string;
41
41
  readonly command: string;
42
42
  readonly detail: string | null;
43
+ /** Grant key an `always` keys its standing grant under; `null` falls back to `command`. */
44
+ readonly grantKey: string | null;
43
45
  readonly createdAt: string;
44
46
  }
45
47
  /** Insert a fresh `pending` ask before its decision is awaited. */
@@ -50,8 +52,8 @@ export declare function insertPendingAsk(querier: Querier, row: AskRow): ResultA
50
52
  * every approval-gated action. The recorded reply is `{ kind: "always" }`.
51
53
  */
52
54
  export declare function insertGrantedAsk(querier: Querier, row: AskRow, resolvedAt: string): ResultAsync<void, DbError>;
53
- /** Does a standing grant cover `(analysisId, command)` in this analysis? */
54
- export declare function selectGrant(querier: Querier, analysisId: string, command: string): ResultAsync<boolean, DbError>;
55
+ /** Does a standing grant cover `(analysisId, grantKey)` in this analysis? */
56
+ export declare function selectGrant(querier: Querier, analysisId: string, grantKey: string): ResultAsync<boolean, DbError>;
55
57
  /** Read a row's current status + recorded reply. `null` when no such row exists. */
56
58
  export declare function selectAskResolution(querier: Querier, id: string): ResultAsync<AskResolution | null, DbError>;
57
59
  /**
@@ -15,9 +15,9 @@ export function insertPendingAsk(querier, row) {
15
15
  return tryMutation("asks.insertPending", async () => {
16
16
  await querier.query({
17
17
  text: `INSERT INTO cortex_asks
18
- (id, analysis_id, thread_id, title, command, detail, status, created_at)
19
- VALUES ($1, $2, $3, $4, $5, $6, 'pending', $7)`,
20
- values: [row.id, row.analysisId, row.threadId, row.title, row.command, row.detail, row.createdAt],
18
+ (id, analysis_id, thread_id, title, command, detail, grant_key, status, created_at)
19
+ VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', $8)`,
20
+ values: [row.id, row.analysisId, row.threadId, row.title, row.command, row.detail, row.grantKey ?? row.command, row.createdAt],
21
21
  });
22
22
  });
23
23
  }
@@ -30,18 +30,29 @@ export function insertGrantedAsk(querier, row, resolvedAt) {
30
30
  return tryMutation("asks.insertGranted", async () => {
31
31
  await querier.query({
32
32
  text: `INSERT INTO cortex_asks
33
- (id, analysis_id, thread_id, title, command, detail, status, reply, created_at, resolved_at)
34
- VALUES ($1, $2, $3, $4, $5, $6, 'resolved', $7::jsonb, $8, $9)`,
35
- values: [row.id, row.analysisId, row.threadId, row.title, row.command, row.detail, JSON.stringify({ kind: "always" }), row.createdAt, resolvedAt],
33
+ (id, analysis_id, thread_id, title, command, detail, grant_key, status, reply, created_at, resolved_at)
34
+ VALUES ($1, $2, $3, $4, $5, $6, $7, 'resolved', $8::jsonb, $9, $10)`,
35
+ values: [
36
+ row.id,
37
+ row.analysisId,
38
+ row.threadId,
39
+ row.title,
40
+ row.command,
41
+ row.detail,
42
+ row.grantKey ?? row.command,
43
+ JSON.stringify({ kind: "always" }),
44
+ row.createdAt,
45
+ resolvedAt,
46
+ ],
36
47
  });
37
48
  });
38
49
  }
39
- /** Does a standing grant cover `(analysisId, command)` in this analysis? */
40
- export function selectGrant(querier, analysisId, command) {
50
+ /** Does a standing grant cover `(analysisId, grantKey)` in this analysis? */
51
+ export function selectGrant(querier, analysisId, grantKey) {
41
52
  return tryQuery("asks.selectGrant", async () => {
42
53
  const result = await querier.query({
43
- text: `SELECT 1 FROM cortex_ask_grants WHERE analysis_id = $1 AND command = $2`,
44
- values: [analysisId, command],
54
+ text: `SELECT 1 FROM cortex_ask_grants WHERE analysis_id = $1 AND grant_key = $2`,
55
+ values: [analysisId, grantKey],
45
56
  });
46
57
  return (result.rowCount ?? 0) > 0;
47
58
  });
@@ -114,19 +125,23 @@ export function answerAsk(pool, id, reply, now) {
114
125
  return withTransaction(pool, "asks.answer", (client) => tryMutation("asks.answer:update", () => client.query({
115
126
  text: `UPDATE cortex_asks SET status = $1, reply = $2::jsonb, resolved_at = $3
116
127
  WHERE id = $4 AND status = 'pending'
117
- RETURNING analysis_id, command`,
128
+ RETURNING analysis_id, COALESCE(grant_key, command) AS grant_key`,
118
129
  values: [status, JSON.stringify(reply), now, id],
119
130
  })).andThen((updated) => {
120
131
  if ((updated.rowCount ?? 0) === 0)
121
132
  return discriminateMiss(client, id);
122
133
  if (reply.kind !== "always")
123
134
  return okAsync("applied");
135
+ // The cast's non-null `grant_key` is sound without any boot-order
136
+ // caveat: every insert writes grant_key (command when the tool supplied
137
+ // none), and the COALESCE above covers any pending row that predates
138
+ // the column — `command` is NOT NULL, so the key can never be null.
124
139
  const granted = updated.rows[0];
125
140
  return tryMutation("asks.answer:grant", async () => {
126
141
  await client.query({
127
- text: `INSERT INTO cortex_ask_grants (analysis_id, command, created_at)
128
- VALUES ($1, $2, $3) ON CONFLICT (analysis_id, command) DO NOTHING`,
129
- values: [granted.analysis_id, granted.command, now],
142
+ text: `INSERT INTO cortex_ask_grants (analysis_id, grant_key, created_at)
143
+ VALUES ($1, $2, $3) ON CONFLICT (analysis_id, grant_key) DO NOTHING`,
144
+ values: [granted.analysis_id, granted.grant_key, now],
130
145
  });
131
146
  }).map(() => "applied");
132
147
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Host-agnostic bioinformatics agent harness \u2014 hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",