@inflexa-ai/harness 0.35.5 → 0.35.7
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.js +10 -2
- package/dist/index.d.ts +6 -0
- package/dist/index.js +17 -0
- package/dist/lib/metrics.d.ts +55 -4
- package/dist/lib/metrics.js +101 -4
- package/dist/prompts/briefing.js +6 -3
- package/dist/prompts/literature-reviewer.d.ts +1 -1
- package/dist/prompts/literature-reviewer.js +16 -8
- package/dist/prompts/planner.d.ts +4 -0
- package/dist/prompts/planner.js +22 -16
- package/dist/sandbox/create-sandbox.js +23 -1
- package/dist/sandbox/exec-outcome.d.ts +45 -0
- package/dist/sandbox/exec-outcome.js +65 -0
- package/dist/sandbox/k8s-client.d.ts +10 -0
- package/dist/sandbox/k8s-client.js +40 -5
- package/dist/sandbox/liveness.d.ts +3 -1
- package/dist/sandbox/types.d.ts +24 -0
- package/dist/sandbox/types.js +24 -0
- package/dist/tools/ad-hoc-router.d.ts +23 -0
- package/dist/tools/ad-hoc-router.js +93 -5
- package/dist/tools/bio/alphafold-prediction.d.ts +19 -0
- package/dist/tools/bio/alphafold-prediction.js +40 -0
- package/dist/tools/bio/index.d.ts +2 -0
- package/dist/tools/bio/index.js +3 -0
- package/dist/tools/bio/search-protein.d.ts +20 -0
- package/dist/tools/bio/search-protein.js +61 -0
- package/dist/tools/execute-analysis.d.ts +2 -1
- package/dist/tools/execute-analysis.js +66 -5
- package/dist/tools/lib/alphafold-client.d.ts +55 -0
- package/dist/tools/lib/alphafold-client.js +72 -0
- package/dist/tools/lib/api-utils.d.ts +25 -0
- package/dist/tools/lib/api-utils.js +45 -3
- package/dist/tools/lib/uniprot-client.d.ts +131 -0
- package/dist/tools/lib/uniprot-client.js +190 -0
- package/dist/tools/research/literature-reviewer.js +2 -0
- package/dist/tools/sandbox/list-available-packages.d.ts +25 -0
- package/dist/tools/sandbox/list-available-packages.js +51 -29
- package/dist/workflows/sandbox-step.js +10 -1
- package/dist/workflows/target-assessment/progress-stream.d.ts +75 -0
- package/dist/workflows/target-assessment/progress-stream.js +110 -0
- package/package.json +1 -1
|
@@ -105,3 +105,193 @@ export async function getReactomePathwaysByUniProt(accession) {
|
|
|
105
105
|
const rec = await getUniProtRecord(accession);
|
|
106
106
|
return rec?.reactomePathwayIds ?? [];
|
|
107
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* The field list of the search. It is separate from `FIELDS` on purpose:
|
|
110
|
+
* `FIELDS` feeds `getUniProtRecord` and the target-assessment dossier, thus a
|
|
111
|
+
* widening of it would change what that workflow reads.
|
|
112
|
+
*/
|
|
113
|
+
const SEARCH_FIELDS = ["accession", "id", "protein_name", "gene_names", "length", "cc_function", "cc_subcellular_location"].join(",");
|
|
114
|
+
/** A FUNCTION comment runs to several hundred words; this is what one answer carries. */
|
|
115
|
+
const MAX_FUNCTION_CHARS = 1200;
|
|
116
|
+
/**
|
|
117
|
+
* `entryType` reads `UniProtKB reviewed (Swiss-Prot)` or
|
|
118
|
+
* `UniProtKB unreviewed (TrEMBL)`. The unreviewed value HOLDS the reviewed one
|
|
119
|
+
* as a substring, thus the negative test must run first. A test for `reviewed`
|
|
120
|
+
* alone answers true for every entry of either kind.
|
|
121
|
+
*/
|
|
122
|
+
const REVIEWED_ENTRY_TYPE = "reviewed";
|
|
123
|
+
const UNREVIEWED_ENTRY_TYPE = "unreviewed";
|
|
124
|
+
/** Is the entry a curated Swiss-Prot record, as opposed to a machine-annotated TrEMBL one? */
|
|
125
|
+
function isReviewedEntry(entryType) {
|
|
126
|
+
const value = (entryType ?? "").toLowerCase();
|
|
127
|
+
if (value.includes(UNREVIEWED_ENTRY_TYPE))
|
|
128
|
+
return false;
|
|
129
|
+
return value.includes(REVIEWED_ENTRY_TYPE);
|
|
130
|
+
}
|
|
131
|
+
const SearchCommentSchema = z.object({
|
|
132
|
+
commentType: z.string().optional(),
|
|
133
|
+
texts: z.array(z.object({ value: z.string().optional() })).optional(),
|
|
134
|
+
subcellularLocations: z.array(z.object({ location: z.object({ value: z.string().optional() }).optional() })).optional(),
|
|
135
|
+
});
|
|
136
|
+
const ProteinNameSchema = z.object({ fullName: z.object({ value: z.string().optional() }).optional() });
|
|
137
|
+
/**
|
|
138
|
+
* One result row of `uniprotkb/search`, with the same all-optional discipline as
|
|
139
|
+
* `UniProtRecordSchema`: UniProt omits the key of an absent value, thus every
|
|
140
|
+
* field carries `.optional()` and none carries `.nullable()`.
|
|
141
|
+
*
|
|
142
|
+
* A TrEMBL row can carry `submissionNames` in place of `recommendedName`
|
|
143
|
+
* (`X5D778` is one), thus the name reader falls back rather than reporting the
|
|
144
|
+
* protein as unnamed.
|
|
145
|
+
*/
|
|
146
|
+
export const UniProtSearchResultSchema = z.object({
|
|
147
|
+
primaryAccession: z.string().optional(),
|
|
148
|
+
uniProtkbId: z.string().optional(),
|
|
149
|
+
entryType: z.string().optional(),
|
|
150
|
+
proteinDescription: z
|
|
151
|
+
.object({
|
|
152
|
+
recommendedName: ProteinNameSchema.optional(),
|
|
153
|
+
submissionNames: z.array(ProteinNameSchema).optional(),
|
|
154
|
+
})
|
|
155
|
+
.optional(),
|
|
156
|
+
genes: z.array(z.object({ geneName: z.object({ value: z.string().optional() }).optional() })).optional(),
|
|
157
|
+
sequence: z.object({ length: z.number().optional() }).optional(),
|
|
158
|
+
comments: z.array(SearchCommentSchema).optional(),
|
|
159
|
+
});
|
|
160
|
+
/** The search envelope. It is exported so that the golden-fixture table drives it. */
|
|
161
|
+
export const UniProtSearchResponseSchema = z.object({
|
|
162
|
+
results: z.array(UniProtSearchResultSchema).optional(),
|
|
163
|
+
});
|
|
164
|
+
/** Read the protein name, preferring the recommended name over a submitted one. */
|
|
165
|
+
function extractProteinName(raw) {
|
|
166
|
+
const description = raw.proteinDescription;
|
|
167
|
+
const recommended = description?.recommendedName?.fullName?.value?.trim();
|
|
168
|
+
if (recommended)
|
|
169
|
+
return recommended;
|
|
170
|
+
for (const submitted of description?.submissionNames ?? []) {
|
|
171
|
+
const value = submitted.fullName?.value?.trim();
|
|
172
|
+
if (value)
|
|
173
|
+
return value;
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
/** Read the first FUNCTION comment, capped at {@link MAX_FUNCTION_CHARS}. */
|
|
178
|
+
function extractFunctionText(raw) {
|
|
179
|
+
for (const comment of raw.comments ?? []) {
|
|
180
|
+
if (comment.commentType !== "FUNCTION")
|
|
181
|
+
continue;
|
|
182
|
+
for (const text of comment.texts ?? []) {
|
|
183
|
+
const value = text.value?.trim();
|
|
184
|
+
if (value)
|
|
185
|
+
return value.length > MAX_FUNCTION_CHARS ? `${value.slice(0, MAX_FUNCTION_CHARS)}…` : value;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Read the distinct subcellular locations. UniProt splits them over more than
|
|
192
|
+
* one SUBCELLULAR LOCATION comment, and it repeats a location across them, thus
|
|
193
|
+
* the reader collects across every comment and keeps the first occurrence only.
|
|
194
|
+
*/
|
|
195
|
+
function extractSubcellularLocations(raw) {
|
|
196
|
+
const locations = [];
|
|
197
|
+
const seen = new Set();
|
|
198
|
+
for (const comment of raw.comments ?? []) {
|
|
199
|
+
if (comment.commentType !== "SUBCELLULAR LOCATION")
|
|
200
|
+
continue;
|
|
201
|
+
for (const entry of comment.subcellularLocations ?? []) {
|
|
202
|
+
const value = entry.location?.value?.trim();
|
|
203
|
+
if (!value || seen.has(value))
|
|
204
|
+
continue;
|
|
205
|
+
seen.add(value);
|
|
206
|
+
locations.push(value);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return locations;
|
|
210
|
+
}
|
|
211
|
+
function toProtein(raw) {
|
|
212
|
+
return {
|
|
213
|
+
accession: raw.primaryAccession ?? "",
|
|
214
|
+
uniProtkbId: raw.uniProtkbId ?? null,
|
|
215
|
+
proteinName: extractProteinName(raw),
|
|
216
|
+
geneNames: (raw.genes ?? []).map((gene) => gene.geneName?.value).filter((name) => Boolean(name)),
|
|
217
|
+
sequenceLength: raw.sequence?.length ?? null,
|
|
218
|
+
function: extractFunctionText(raw),
|
|
219
|
+
subcellularLocations: extractSubcellularLocations(raw),
|
|
220
|
+
reviewed: isReviewedEntry(raw.entryType),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* The accession forms that UniProt itself documents: the six-character form and
|
|
225
|
+
* the ten-character form, with an optional isoform suffix.
|
|
226
|
+
*
|
|
227
|
+
* The shape does NOT tell an accession from a gene symbol, because the two
|
|
228
|
+
* spaces overlap: `P2RY12`, `B3GAT1`, and their siblings are real gene symbols
|
|
229
|
+
* that match this form. Thus the match decides only whether the `accession:`
|
|
230
|
+
* clause is SAFE to send, never which space the query names.
|
|
231
|
+
*/
|
|
232
|
+
const ACCESSION_RE = /^(?:[OPQ][0-9][A-Z0-9]{3}[0-9]|[A-NR-Z][0-9](?:[A-Z][A-Z0-9]{2}[0-9]){1,2})(?:-\d+)?$/i;
|
|
233
|
+
/**
|
|
234
|
+
* Can the query ride in an `accession:` clause?
|
|
235
|
+
*
|
|
236
|
+
* UniProt validates the value of that clause, and it answers HTTP 400 for a
|
|
237
|
+
* value that is not accession-shaped. Thus a plain symbol such as `BRCA1` must
|
|
238
|
+
* never reach one, because the 400 fails the whole request.
|
|
239
|
+
*/
|
|
240
|
+
export function isUniProtAccession(query) {
|
|
241
|
+
return ACCESSION_RE.test(query.trim());
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Build the UniProtKB query.
|
|
245
|
+
*
|
|
246
|
+
* The organism filter and the reviewed filter narrow an ambiguous SYMBOL
|
|
247
|
+
* search. An accession is a unique key, thus the filters must not reach it: a
|
|
248
|
+
* lookup of `P02769` under the human default would otherwise answer nothing,
|
|
249
|
+
* although the accession names bovine serum albumin.
|
|
250
|
+
*
|
|
251
|
+
* An accession-shaped input is ambiguous, because the two identifier spaces
|
|
252
|
+
* overlap. Such an input searches both, and the unfiltered accession clause is
|
|
253
|
+
* OR-ed with the filtered symbol clause. An input that is not
|
|
254
|
+
* accession-shaped searches the symbol space alone.
|
|
255
|
+
*
|
|
256
|
+
* The accession side carries `active:true`, and that is the one filter it
|
|
257
|
+
* takes. A deleted accession still answers an `accession:` query, as an
|
|
258
|
+
* `Inactive` row with no name and no function, and `UniProtProtein` has no
|
|
259
|
+
* field for the deletion reason. `B3GAT1` is such a case: the gene symbol also
|
|
260
|
+
* names a deleted TrEMBL accession, thus without the filter the answer holds
|
|
261
|
+
* the human protein and a hollow second row.
|
|
262
|
+
*/
|
|
263
|
+
function buildSearchQuery(query, organismId, reviewedOnly) {
|
|
264
|
+
const symbolClauses = [`gene_exact:${query}`];
|
|
265
|
+
if (organismId !== undefined)
|
|
266
|
+
symbolClauses.push(`organism_id:${organismId}`);
|
|
267
|
+
if (reviewedOnly)
|
|
268
|
+
symbolClauses.push("reviewed:true");
|
|
269
|
+
const symbolQuery = symbolClauses.join(" AND ");
|
|
270
|
+
if (!isUniProtAccession(query))
|
|
271
|
+
return symbolQuery;
|
|
272
|
+
return `((accession:${query} AND active:true) OR (${symbolQuery}))`;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Search UniProtKB for a protein by gene symbol or by accession.
|
|
276
|
+
*
|
|
277
|
+
* An unknown query is HTTP 200 with `{"results":[]}`, never a 404, thus the
|
|
278
|
+
* empty result is an ordinary answer and never an error. The request asks for
|
|
279
|
+
* one row beyond `limit`, because UniProt reports its match count in the
|
|
280
|
+
* `x-total-results` header and `apiFetch` exposes no header. The extra row is
|
|
281
|
+
* what separates a trimmed answer from a complete one.
|
|
282
|
+
*/
|
|
283
|
+
export async function searchProteins(query, opts = {}) {
|
|
284
|
+
const { organismId, reviewedOnly = true, limit = 10 } = opts;
|
|
285
|
+
const trimmed = query.trim();
|
|
286
|
+
const params = new URLSearchParams({
|
|
287
|
+
query: buildSearchQuery(trimmed, organismId, reviewedOnly),
|
|
288
|
+
fields: SEARCH_FIELDS,
|
|
289
|
+
format: "json",
|
|
290
|
+
size: String(limit + 1),
|
|
291
|
+
});
|
|
292
|
+
const res = await apiFetchValidated(`${UNIPROT_BASE}/uniprotkb/search?${params.toString()}`, UniProtSearchResponseSchema, { headers: UNIPROT_HEADERS });
|
|
293
|
+
if (res.isErr())
|
|
294
|
+
throw new Error(describeApiError(res.error));
|
|
295
|
+
const rows = res.value.results ?? [];
|
|
296
|
+
return { proteins: rows.slice(0, limit).map(toProtein), hasMore: rows.length > limit };
|
|
297
|
+
}
|
|
@@ -26,6 +26,7 @@ import { createChemDbTools, createNcbiTools } from "../bio/keys.js";
|
|
|
26
26
|
import { genePreclinicalProfileTool } from "../bio/gene-preclinical-profile.js";
|
|
27
27
|
import { lookupAnnotationTool } from "../bio/lookup-annotation.js";
|
|
28
28
|
import { searchGeneTool } from "../bio/search-gene.js";
|
|
29
|
+
import { searchProteinTool } from "../bio/search-protein.js";
|
|
29
30
|
import { searchInteractionsTool } from "../bio/search-interactions.js";
|
|
30
31
|
import { createResolveCitationTool } from "./resolve-citation.js";
|
|
31
32
|
/** Sub-agent identity — appended to `callPath`, set as `agentId`. */
|
|
@@ -38,6 +39,7 @@ export function createLiteratureReviewerTool(deps) {
|
|
|
38
39
|
const chemDb = createChemDbTools(deps.bioKeys, { ...(deps.logger ? { logger: deps.logger } : {}) });
|
|
39
40
|
const reviewerTools = [
|
|
40
41
|
searchGeneTool,
|
|
42
|
+
searchProteinTool,
|
|
41
43
|
lookupAnnotationTool,
|
|
42
44
|
searchInteractionsTool,
|
|
43
45
|
ncbi.pubmed,
|
|
@@ -116,6 +116,31 @@ export type ListAvailablePackagesDeps = Pick<EnvironmentStorePaths, "farmLockFil
|
|
|
116
116
|
*/
|
|
117
117
|
readonly linkToolPresent?: boolean;
|
|
118
118
|
};
|
|
119
|
+
/**
|
|
120
|
+
* What one inventory read produced: the sections of the bound source, or the
|
|
121
|
+
* fact that the source cannot answer, with the reason where the source gives
|
|
122
|
+
* one. There is no empty-on-error shape — an unreadable inventory must stay
|
|
123
|
+
* distinguishable from an inventory that holds nothing, and a caller that
|
|
124
|
+
* cannot tell them apart reports a present package as absent.
|
|
125
|
+
*/
|
|
126
|
+
export type InventoryRead = {
|
|
127
|
+
readonly kind: "sections";
|
|
128
|
+
readonly sections: readonly Section[];
|
|
129
|
+
} | {
|
|
130
|
+
readonly kind: "unavailable";
|
|
131
|
+
readonly reason?: string;
|
|
132
|
+
};
|
|
133
|
+
/**
|
|
134
|
+
* Read the inventory of the bound source: the pool-scope reader when the
|
|
135
|
+
* embedder binds one (a conversation or planning surface), the first readable
|
|
136
|
+
* farm `inflexa.lock` otherwise (a sandbox agent). The image record merges
|
|
137
|
+
* into either report.
|
|
138
|
+
*
|
|
139
|
+
* Every caller that resolves a name against the environment reads through
|
|
140
|
+
* here, thus the census a model sees and the check a launch runs never
|
|
141
|
+
* disagree about what the source holds.
|
|
142
|
+
*/
|
|
143
|
+
export declare function readInventorySections(deps: ListAvailablePackagesDeps): Promise<InventoryRead>;
|
|
119
144
|
/**
|
|
120
145
|
* Create the package inventory over the bound source: the pool-scope reader
|
|
121
146
|
* when the embedder binds one (a conversation or planning surface), the
|
|
@@ -390,6 +390,50 @@ export function queryPackages(rawSections, { names, query, language, limit }) {
|
|
|
390
390
|
const { content, returned } = renderListing(filtered, limit ?? DEFAULT_LIMIT, bothTrackNames(sections));
|
|
391
391
|
return { available: true, total, returned, hasMore: returned < total, content };
|
|
392
392
|
}
|
|
393
|
+
/**
|
|
394
|
+
* Read the inventory of the bound source: the pool-scope reader when the
|
|
395
|
+
* embedder binds one (a conversation or planning surface), the first readable
|
|
396
|
+
* farm `inflexa.lock` otherwise (a sandbox agent). The image record merges
|
|
397
|
+
* into either report.
|
|
398
|
+
*
|
|
399
|
+
* Every caller that resolves a name against the environment reads through
|
|
400
|
+
* here, thus the census a model sees and the check a launch runs never
|
|
401
|
+
* disagree about what the source holds.
|
|
402
|
+
*/
|
|
403
|
+
export async function readInventorySections(deps) {
|
|
404
|
+
// The image record merges in when the host can read a valid one. An absent
|
|
405
|
+
// record and an invalid record both merge nothing, and neither is an
|
|
406
|
+
// error: the record is an enrichment, and the farm or pool inventory stays
|
|
407
|
+
// whole without it. A store packed before the record existed carries none.
|
|
408
|
+
// There is no logger here, thus an invalid record cannot be reported — the
|
|
409
|
+
// schema of the harness refuses it, and the report degrades to the tracks
|
|
410
|
+
// that it could read.
|
|
411
|
+
const recordSections = readImagePackagesFile(deps.imagePackagesFile ?? DEFAULT_IMAGE_PACKAGES_FILE)
|
|
412
|
+
.map(imageSections)
|
|
413
|
+
.unwrapOr([]);
|
|
414
|
+
if (deps.readPoolInventory) {
|
|
415
|
+
const pool = await deps.readPoolInventory().catch((cause) => ({
|
|
416
|
+
kind: "unavailable",
|
|
417
|
+
reason: cause instanceof Error ? cause.message : String(cause),
|
|
418
|
+
}));
|
|
419
|
+
if (pool.kind === "unavailable")
|
|
420
|
+
return { kind: "unavailable", reason: pool.reason };
|
|
421
|
+
return { kind: "sections", sections: [...pool.sections, ...recordSections] };
|
|
422
|
+
}
|
|
423
|
+
// Both farm container paths are tried when the host injects none, because
|
|
424
|
+
// the path keys on the declared toolchain and this read carries none.
|
|
425
|
+
let lock = null;
|
|
426
|
+
for (const candidate of deps.farmLockFile ? [deps.farmLockFile] : DEFAULT_FARM_LOCK_FILES) {
|
|
427
|
+
lock = readFarmLockFile(candidate).unwrapOr(null);
|
|
428
|
+
if (lock !== null)
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
// A lock that no candidate path holds is an absence with no reason to
|
|
432
|
+
// report: the read never got far enough to learn one.
|
|
433
|
+
if (lock === null)
|
|
434
|
+
return { kind: "unavailable" };
|
|
435
|
+
return { kind: "sections", sections: [...lockSections(lock), ...recordSections] };
|
|
436
|
+
}
|
|
393
437
|
/**
|
|
394
438
|
* Create the package inventory over the bound source: the pool-scope reader
|
|
395
439
|
* when the embedder binds one (a conversation or planning surface), the
|
|
@@ -397,8 +441,6 @@ export function queryPackages(rawSections, { names, query, language, limit }) {
|
|
|
397
441
|
* record merges into either report.
|
|
398
442
|
*/
|
|
399
443
|
export function createListAvailablePackagesTool(deps = {}) {
|
|
400
|
-
const lockCandidates = deps.farmLockFile ? [deps.farmLockFile] : DEFAULT_FARM_LOCK_FILES;
|
|
401
|
-
const imagePackagesFile = deps.imagePackagesFile ?? DEFAULT_IMAGE_PACKAGES_FILE;
|
|
402
444
|
const readPoolInventory = deps.readPoolInventory;
|
|
403
445
|
// The scope decides the framing sentence: a sandbox agent reads what it can
|
|
404
446
|
// import NOW, and a conversation surface reads what the store HOLDS — a
|
|
@@ -461,38 +503,18 @@ export function createListAvailablePackagesTool(deps = {}) {
|
|
|
461
503
|
return "full package list";
|
|
462
504
|
},
|
|
463
505
|
execute: async (input) => {
|
|
464
|
-
|
|
465
|
-
// An absent record and an invalid record both merge nothing, and
|
|
466
|
-
// neither is an error: the record is an enrichment, and the farm
|
|
467
|
-
// or pool inventory stays whole without it. A store packed before
|
|
468
|
-
// the record existed carries none. The tool holds no logger, thus
|
|
469
|
-
// an invalid record cannot be reported here — the schema of the
|
|
470
|
-
// harness refuses it, and the report degrades to the tracks it
|
|
471
|
-
// could read.
|
|
472
|
-
const recordSections = readImagePackagesFile(imagePackagesFile).map(imageSections).unwrapOr([]);
|
|
506
|
+
const read = await readInventorySections(deps);
|
|
473
507
|
// An unreadable inventory is an expected environment state — model it as an
|
|
474
508
|
// `available: false` data variant telling the caller the set is UNKNOWN,
|
|
475
509
|
// WITH the reason: without it, a structural fault (a damaged dependency
|
|
476
510
|
// graph) reads as a transient flake, and the caller retries for ever.
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
})
|
|
482
|
-
if (pool.kind === "unavailable")
|
|
483
|
-
return ok({ available: false, content: `${POOL_UNAVAILABLE_NOTE} The reason: ${pool.reason}.` });
|
|
484
|
-
return ok(queryPackages([...pool.sections, ...recordSections], input));
|
|
485
|
-
}
|
|
486
|
-
let lock = null;
|
|
487
|
-
for (const candidate of lockCandidates) {
|
|
488
|
-
lock = readFarmLockFile(candidate).unwrapOr(null);
|
|
489
|
-
if (lock !== null)
|
|
490
|
-
break;
|
|
491
|
-
}
|
|
492
|
-
if (lock === null) {
|
|
493
|
-
return ok({ available: false, content: UNAVAILABLE_NOTE });
|
|
511
|
+
// The note follows the vantage of the bound source, because a
|
|
512
|
+
// conversation surface has no runtime to probe.
|
|
513
|
+
if (read.kind === "unavailable") {
|
|
514
|
+
const note = readPoolInventory ? POOL_UNAVAILABLE_NOTE : UNAVAILABLE_NOTE;
|
|
515
|
+
return ok({ available: false, content: read.reason === undefined ? note : `${note} The reason: ${read.reason}.` });
|
|
494
516
|
}
|
|
495
|
-
return ok(queryPackages(
|
|
517
|
+
return ok(queryPackages(read.sections, input));
|
|
496
518
|
},
|
|
497
519
|
});
|
|
498
520
|
}
|
|
@@ -34,6 +34,7 @@ import { unwrapOrThrow } from "../lib/result.js";
|
|
|
34
34
|
import { isBudgetExceeded } from "../loop/budget-exceeded.js";
|
|
35
35
|
import { runAgent } from "../loop/run-agent.js";
|
|
36
36
|
import { durableStep } from "../loop/run-step.js";
|
|
37
|
+
import { lastExecOutcome } from "../sandbox/exec-outcome.js";
|
|
37
38
|
import { activityForTool, applyTreeDelta, isChatDataPart, sandboxTreeDelta, stepPartId } from "../sandbox/sandbox-step-translate.js";
|
|
38
39
|
import { createDetailResolver } from "../tools/detail-resolver.js";
|
|
39
40
|
import { forSubAgent } from "../auth/types.js";
|
|
@@ -274,8 +275,15 @@ export async function runSandboxStepBody(input, deps) {
|
|
|
274
275
|
// The scrub below is deliberate and load-bearing, which makes this record
|
|
275
276
|
// the ONLY account of why the step died — the thrown error carries just the
|
|
276
277
|
// generic phrase, so nothing downstream can reconstruct the cause.
|
|
278
|
+
// `durationMs`, the iteration cap, and the last exec of the sandbox are
|
|
279
|
+
// what separate a step that died in the model from one that died in the
|
|
280
|
+
// machine. `lastExec` holds no stream text (see `exec-outcome.ts`).
|
|
281
|
+
const lastExec = lastExecOutcome(sandbox.sandboxId);
|
|
277
282
|
logger.error("step failure", {
|
|
278
283
|
errorClass,
|
|
284
|
+
durationMs,
|
|
285
|
+
hitMaxSteps,
|
|
286
|
+
...(lastExec ? { lastExec } : {}),
|
|
279
287
|
...logger.errorFields(err),
|
|
280
288
|
});
|
|
281
289
|
const safe = userFacingStepFailure(errorClass);
|
|
@@ -341,7 +349,8 @@ export async function runSandboxStepBody(input, deps) {
|
|
|
341
349
|
const neverAbort = new AbortController().signal;
|
|
342
350
|
let transcript;
|
|
343
351
|
let finishReason;
|
|
344
|
-
|
|
352
|
+
// `false` until the loop reports otherwise: a loop that throws never reached its cap.
|
|
353
|
+
let hitMaxSteps = false;
|
|
345
354
|
let stepUsage;
|
|
346
355
|
try {
|
|
347
356
|
const agentResult = await runAgent(agent, initial, session, {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TargetAssessmentProgressStream — the read side of a target assessment's
|
|
3
|
+
* durable progress stream.
|
|
4
|
+
*
|
|
5
|
+
* `emitProgress` (the sibling `progress.ts`) writes one part per phase
|
|
6
|
+
* transition and nothing reads it back, so an embedder that wants to show a
|
|
7
|
+
* user how far an assessment has got had to reach for the durability engine
|
|
8
|
+
* itself. This seam is that read, and it quarantines the engine the way
|
|
9
|
+
* `RunLauncher` does for starting workflows: the delivered value is the
|
|
10
|
+
* harness's own `TargetAssessmentProgressEvent` contract type, and no engine
|
|
11
|
+
* type appears in the exported signature.
|
|
12
|
+
*
|
|
13
|
+
* It is far simpler than `execution/run-event-stream.ts`, and deliberately so.
|
|
14
|
+
* A run fans out over a parent workflow and every sandbox-step child, each
|
|
15
|
+
* writing its own stream, so that seam polls a ledger to discover children and
|
|
16
|
+
* folds reconciling parts before delivery. A target assessment writes progress
|
|
17
|
+
* from the parent body alone, under `workflowID === assessmentId`. One stream,
|
|
18
|
+
* no discovery, no pool.
|
|
19
|
+
*
|
|
20
|
+
* It is a push subscription rather than a returned iterator because handing the
|
|
21
|
+
* engine's async generator back to a caller would put the engine's model on the
|
|
22
|
+
* very surface this seam exists to remove, and would leave nowhere to contain a
|
|
23
|
+
* failure. A handler gives both, and matches the `EmitFn` idiom the writer side
|
|
24
|
+
* already speaks.
|
|
25
|
+
*/
|
|
26
|
+
import { type TargetAssessmentProgressEvent } from "@inflexa-ai/harness/contracts/target-dossier.js";
|
|
27
|
+
import type { Logger } from "../../lib/logger.js";
|
|
28
|
+
/**
|
|
29
|
+
* Construction-time dependencies. The logger is where every contained failure
|
|
30
|
+
* is reported, and defaults to silence so an embedder that wired none sees
|
|
31
|
+
* nothing rather than having console forced on it.
|
|
32
|
+
*/
|
|
33
|
+
export interface TargetAssessmentProgressStreamDeps {
|
|
34
|
+
readonly logger?: Logger;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Receives one progress event. May be async — the subscription awaits it, so a
|
|
38
|
+
* handler is never invoked concurrently with itself. A throw is logged and
|
|
39
|
+
* swallowed.
|
|
40
|
+
*/
|
|
41
|
+
export type TargetAssessmentProgressHandler = (event: TargetAssessmentProgressEvent) => void | Promise<void>;
|
|
42
|
+
/** Call-time parameters of one subscription. */
|
|
43
|
+
export interface TargetAssessmentProgressSubscribeOptions {
|
|
44
|
+
/** The assessment to observe. Also the workflow's id — TA sets the two equal. */
|
|
45
|
+
readonly assessmentId: string;
|
|
46
|
+
/** Where each delivered event goes. */
|
|
47
|
+
readonly onEvent: TargetAssessmentProgressHandler;
|
|
48
|
+
/** Aborting stops delivery and settles the returned promise. */
|
|
49
|
+
readonly signal: AbortSignal;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The progress read seam. One method, because an assessment-scoped subscription
|
|
53
|
+
* is the whole capability — a general-purpose stream reader would leak the
|
|
54
|
+
* engine's model back into the surface this exists to quarantine.
|
|
55
|
+
*/
|
|
56
|
+
export interface TargetAssessmentProgressStream {
|
|
57
|
+
/**
|
|
58
|
+
* Deliver every progress event the assessment produces to `onEvent`,
|
|
59
|
+
* resolving when the assessment's stream drains or when the signal aborts.
|
|
60
|
+
*
|
|
61
|
+
* The stream drains when the workflow stops being active. A workflow that
|
|
62
|
+
* self-cancels on a 402 is no longer active, so the stream drains at the
|
|
63
|
+
* `suspended` phase and this settles there — it does NOT wait for a later
|
|
64
|
+
* `DBOS.resumeWorkflow`. Re-opening across a resume would mean polling the
|
|
65
|
+
* row's status, which is the caller's business, not this seam's.
|
|
66
|
+
*
|
|
67
|
+
* Events arrive in the order the workflow wrote them, each exactly once.
|
|
68
|
+
* There is no fold: the body emits each phase once and the engine caches a
|
|
69
|
+
* step's stream-write offset, so a recovered workflow does not re-write one.
|
|
70
|
+
* A fold would collapse nothing and cost a buffer.
|
|
71
|
+
*/
|
|
72
|
+
subscribe(options: TargetAssessmentProgressSubscribeOptions): Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
/** Build the DBOS-backed realization of {@link TargetAssessmentProgressStream}. */
|
|
75
|
+
export declare function createTargetAssessmentProgressStream(deps?: TargetAssessmentProgressStreamDeps): TargetAssessmentProgressStream;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TargetAssessmentProgressStream — the read side of a target assessment's
|
|
3
|
+
* durable progress stream.
|
|
4
|
+
*
|
|
5
|
+
* `emitProgress` (the sibling `progress.ts`) writes one part per phase
|
|
6
|
+
* transition and nothing reads it back, so an embedder that wants to show a
|
|
7
|
+
* user how far an assessment has got had to reach for the durability engine
|
|
8
|
+
* itself. This seam is that read, and it quarantines the engine the way
|
|
9
|
+
* `RunLauncher` does for starting workflows: the delivered value is the
|
|
10
|
+
* harness's own `TargetAssessmentProgressEvent` contract type, and no engine
|
|
11
|
+
* type appears in the exported signature.
|
|
12
|
+
*
|
|
13
|
+
* It is far simpler than `execution/run-event-stream.ts`, and deliberately so.
|
|
14
|
+
* A run fans out over a parent workflow and every sandbox-step child, each
|
|
15
|
+
* writing its own stream, so that seam polls a ledger to discover children and
|
|
16
|
+
* folds reconciling parts before delivery. A target assessment writes progress
|
|
17
|
+
* from the parent body alone, under `workflowID === assessmentId`. One stream,
|
|
18
|
+
* no discovery, no pool.
|
|
19
|
+
*
|
|
20
|
+
* It is a push subscription rather than a returned iterator because handing the
|
|
21
|
+
* engine's async generator back to a caller would put the engine's model on the
|
|
22
|
+
* very surface this seam exists to remove, and would leave nowhere to contain a
|
|
23
|
+
* failure. A handler gives both, and matches the `EmitFn` idiom the writer side
|
|
24
|
+
* already speaks.
|
|
25
|
+
*/
|
|
26
|
+
import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
27
|
+
import { TargetAssessmentProgressEventSchema } from "@inflexa-ai/harness/contracts/target-dossier.js";
|
|
28
|
+
import { createNoopLogger } from "../../lib/console-logger.js";
|
|
29
|
+
import { TA_PROGRESS_STREAM_KEY } from "./progress.js";
|
|
30
|
+
/**
|
|
31
|
+
* Shape `emitProgress` writes. The `data-target-assessment-progress` key is not
|
|
32
|
+
* in the chat-part registry — it is the writer's private envelope — so the
|
|
33
|
+
* payload, not the envelope, is what a caller gets.
|
|
34
|
+
*/
|
|
35
|
+
function readEvent(value) {
|
|
36
|
+
if (typeof value !== "object" || value === null)
|
|
37
|
+
return null;
|
|
38
|
+
const parsed = TargetAssessmentProgressEventSchema.safeParse(value.payload);
|
|
39
|
+
return parsed.success ? parsed.data : null;
|
|
40
|
+
}
|
|
41
|
+
/** Build the DBOS-backed realization of {@link TargetAssessmentProgressStream}. */
|
|
42
|
+
export function createTargetAssessmentProgressStream(deps = {}) {
|
|
43
|
+
const baseLogger = (deps.logger ?? createNoopLogger()).named("ta-progress-stream");
|
|
44
|
+
return {
|
|
45
|
+
async subscribe({ assessmentId, onEvent, signal }) {
|
|
46
|
+
const logger = baseLogger.with({ assessmentId });
|
|
47
|
+
let resolveAbort;
|
|
48
|
+
const aborted = new Promise((resolve) => {
|
|
49
|
+
resolveAbort = resolve;
|
|
50
|
+
});
|
|
51
|
+
const generator = DBOS.readStream(assessmentId, TA_PROGRESS_STREAM_KEY);
|
|
52
|
+
// The engine's reader exposes no cancellation: while it waits on a
|
|
53
|
+
// quiet-but-active workflow it is suspended inside an await, where a
|
|
54
|
+
// queued `return()` is only honoured once it next reaches a yield.
|
|
55
|
+
// Teardown is therefore best-effort by construction, and the read's
|
|
56
|
+
// own promise is raced against the signal rather than waiting on it.
|
|
57
|
+
const onAbort = () => {
|
|
58
|
+
resolveAbort();
|
|
59
|
+
void generator.return(undefined).catch(() => {
|
|
60
|
+
/* the read is already being abandoned */
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
if (signal.aborted)
|
|
64
|
+
onAbort();
|
|
65
|
+
else
|
|
66
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
67
|
+
const read = (async () => {
|
|
68
|
+
try {
|
|
69
|
+
for (;;) {
|
|
70
|
+
const next = await generator.next();
|
|
71
|
+
// Checked after the read, not before: `next()` is what
|
|
72
|
+
// notices the workflow going inactive, and an aborted
|
|
73
|
+
// subscription must deliver nothing further.
|
|
74
|
+
if (next.done === true || signal.aborted)
|
|
75
|
+
return;
|
|
76
|
+
const event = readEvent(next.value);
|
|
77
|
+
if (event === null) {
|
|
78
|
+
logger.debug("dropped a stream value outside the progress contract");
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
await onEvent(event);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
logger.warn("progress handler threw", { phase: event.phase, ...logger.errorFields(err) });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
// Observation is a diagnostic channel: `emitProgress` already
|
|
91
|
+
// treats a failed write as best-effort so a lost frame cannot
|
|
92
|
+
// fail the workflow. A read that propagated here would be
|
|
93
|
+
// strictly less robust than the writer it observes.
|
|
94
|
+
logger.error("progress stream read failed", logger.errorFields(err));
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
void generator.return(undefined).catch(() => {
|
|
98
|
+
/* already finished, or abandoned under abort */
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
})();
|
|
102
|
+
try {
|
|
103
|
+
await Promise.race([read, aborted]);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
signal.removeEventListener("abort", onAbort);
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
package/package.json
CHANGED