@johpaz/hive-sdk 0.0.18 → 0.1.3
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/bun.lock +291 -1
- package/docs/HIVE-HARNESS.md +113 -0
- package/package.json +36 -2
- package/packages/cli/package.json +1 -1
- package/packages/core/package.json +13 -2
- package/packages/core/src/ace/Tracer.ts +1 -1
- package/packages/core/src/agent/AgentRunner.ts +12 -0
- package/packages/core/src/agent/ContextCompiler.ts +4 -4
- package/packages/core/src/agent/ConversationStore.ts +30 -20
- package/packages/core/src/agent/selectors/PlaybookSelector.ts +50 -76
- package/packages/core/src/agent/selectors/SkillSelector.ts +106 -262
- package/packages/core/src/agent/selectors/ToolSelector.ts +53 -89
- package/packages/core/src/auth/auth.ts +36 -23
- package/packages/core/src/harness/boot-id.ts +20 -0
- package/packages/core/src/harness/collections.ts +98 -0
- package/packages/core/src/harness/db-helpers.ts +87 -0
- package/packages/core/src/harness/durable-queue.ts +337 -0
- package/packages/core/src/harness/goal-verifier.ts +141 -0
- package/packages/core/src/harness/harness.test.ts +236 -0
- package/packages/core/src/harness/index.ts +34 -0
- package/packages/core/src/harness/job-store.ts +399 -0
- package/packages/core/src/harness/proof-packet.ts +69 -0
- package/packages/core/src/harness/reconcile.ts +149 -0
- package/packages/core/src/harness/run-epoch.ts +32 -0
- package/packages/core/src/harness/run-store.ts +334 -0
- package/packages/core/src/index.ts +6 -0
- package/packages/core/src/memory/Scratchpad.test.ts +23 -21
- package/packages/core/src/memory/Scratchpad.ts +41 -24
- package/packages/core/src/storage/HiveDBStorage.ts +64 -0
- package/packages/core/src/storage/SQLiteStorage.ts +7 -0
- package/packages/core/src/storage/hiveSeed.ts +308 -0
- package/packages/core/src/storage/hiveStorage.test.ts +38 -0
- package/packages/core/src/storage/index.ts +10 -0
- package/packages/core/src/storage/seed.ts +5 -1
- package/packages/core/src/storage/usage.ts +106 -167
- package/packages/core/src/tool-runtime/tool-runtime.test.ts +11 -3
- package/packages/core/src/tools/agents/get-available-models.ts +52 -56
- package/packages/core/src/tools/agents/index.ts +77 -60
- package/packages/core/src/tools/core/index.ts +106 -291
- package/packages/core/src/tools/meeting/index.ts +83 -93
- package/packages/core/src/utils/toon.ts +4 -4
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DurableLaneQueue — persistent work queue backed by the harness_jobQueue
|
|
3
|
+
* HiveDB collection. Ported from `hive`'s gateway/durable-queue.ts,
|
|
4
|
+
* generalized for SDK consumers (job `type` is a plain string; the set of
|
|
5
|
+
* "never auto-retry a logical failure" types is configurable instead of a
|
|
6
|
+
* hardcoded `chat_turn` check — default matches `hive`'s convention).
|
|
7
|
+
*
|
|
8
|
+
* Guarantees:
|
|
9
|
+
* - FIFO + priority per lane
|
|
10
|
+
* - 1 concurrent running job per lane
|
|
11
|
+
* - maxGlobalConcurrency across all lanes (default 4)
|
|
12
|
+
* - JobDoc persists every status transition → survives crashes
|
|
13
|
+
* - Expired leases (crashed worker) are reclaimed or interrupted on next dispatch
|
|
14
|
+
* - Logical failures ({ok:false, retryable:true}) retry with backoff+jitter
|
|
15
|
+
* via `failJobOrRetry`, except for `nonRetryableTypes` (default: chat_turn)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { logger } from "../utils/logger";
|
|
19
|
+
import {
|
|
20
|
+
createJob,
|
|
21
|
+
claimJob,
|
|
22
|
+
completeJob,
|
|
23
|
+
failJob,
|
|
24
|
+
failJobOrRetry,
|
|
25
|
+
cancelJob,
|
|
26
|
+
reclaimOrInterrupt,
|
|
27
|
+
findPendingJobsByLane,
|
|
28
|
+
findAllPendingJobs,
|
|
29
|
+
findExpiredLeases,
|
|
30
|
+
DEFAULT_JOB_RETRY_POLICY,
|
|
31
|
+
type JobRetryPolicy,
|
|
32
|
+
} from "./job-store";
|
|
33
|
+
import type { JobDoc } from "./collections";
|
|
34
|
+
import { getBootId } from "./boot-id";
|
|
35
|
+
|
|
36
|
+
const log = logger.child("harness:durable-queue");
|
|
37
|
+
|
|
38
|
+
const LEASE_CHECK_INTERVAL_MS = 10_000;
|
|
39
|
+
const DEFAULT_MAX_GLOBAL_CONCURRENCY = 4;
|
|
40
|
+
const DEFAULT_TASK_TIMEOUT_MS = 30 * 60 * 1000;
|
|
41
|
+
const DEFAULT_NON_RETRYABLE_TYPES = ["chat_turn"];
|
|
42
|
+
|
|
43
|
+
export interface JobPayload {
|
|
44
|
+
[key: string]: unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface JobExecutorResult {
|
|
48
|
+
ok: boolean;
|
|
49
|
+
result?: unknown;
|
|
50
|
+
error?: string;
|
|
51
|
+
/** Logical failures default to retryable; set false for terminal errors (validation, not-found) that a retry can't fix. Ignored when ok=true. */
|
|
52
|
+
retryable?: boolean;
|
|
53
|
+
/** Stable failure code for observability/tooling, e.g. {code:"PROVIDER_TIMEOUT"}. */
|
|
54
|
+
failureSignature?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface JobLiveCallbacks {
|
|
58
|
+
onToken?: (token: string) => void;
|
|
59
|
+
onStep?: (step: unknown) => Promise<void>;
|
|
60
|
+
sendRaw?: (payload: string) => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type JobExecutor = (
|
|
64
|
+
job: JobDoc,
|
|
65
|
+
signal: AbortSignal,
|
|
66
|
+
callbacks?: JobLiveCallbacks
|
|
67
|
+
) => Promise<JobExecutorResult>;
|
|
68
|
+
|
|
69
|
+
const executors = new Map<string, JobExecutor>();
|
|
70
|
+
|
|
71
|
+
export function registerExecutor(type: string, executor: JobExecutor): void {
|
|
72
|
+
executors.set(type, executor);
|
|
73
|
+
log.info(`[registerExecutor] Registered executor for type=${type}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface DurableLaneQueueOptions {
|
|
77
|
+
maxGlobalConcurrency?: number;
|
|
78
|
+
taskTimeoutMs?: number;
|
|
79
|
+
jobRetryPolicy?: JobRetryPolicy;
|
|
80
|
+
/** Job types that should never auto-retry a logical failure (e.g. user-facing turns). Default: ["chat_turn"]. */
|
|
81
|
+
nonRetryableTypes?: string[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class DurableLaneQueue {
|
|
85
|
+
private maxGlobalConcurrency: number;
|
|
86
|
+
private taskTimeoutMs: number;
|
|
87
|
+
private jobRetryPolicy: JobRetryPolicy;
|
|
88
|
+
private nonRetryableTypes: Set<string>;
|
|
89
|
+
private runningCount = 0;
|
|
90
|
+
private runningAborts = new Map<string, { lane: string; controller: AbortController }>();
|
|
91
|
+
private dispatchTimers = new Map<string, ReturnType<typeof setInterval>>();
|
|
92
|
+
private leaseCheckTimer: ReturnType<typeof setInterval> | null = null;
|
|
93
|
+
private bootId: string;
|
|
94
|
+
|
|
95
|
+
constructor(options: DurableLaneQueueOptions = {}) {
|
|
96
|
+
this.maxGlobalConcurrency = options.maxGlobalConcurrency ?? DEFAULT_MAX_GLOBAL_CONCURRENCY;
|
|
97
|
+
this.taskTimeoutMs = options.taskTimeoutMs ?? DEFAULT_TASK_TIMEOUT_MS;
|
|
98
|
+
this.jobRetryPolicy = options.jobRetryPolicy ?? DEFAULT_JOB_RETRY_POLICY;
|
|
99
|
+
this.nonRetryableTypes = new Set(options.nonRetryableTypes ?? DEFAULT_NON_RETRYABLE_TYPES);
|
|
100
|
+
this.bootId = getBootId();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Enqueue a durable job. Returns the JobDoc. Callers should use `enqueue`
|
|
105
|
+
* instead of any in-memory queue for work that must survive crashes.
|
|
106
|
+
*/
|
|
107
|
+
async enqueue(input: {
|
|
108
|
+
lane: string;
|
|
109
|
+
type: string;
|
|
110
|
+
payload: JobPayload;
|
|
111
|
+
run_id: string;
|
|
112
|
+
priority?: number;
|
|
113
|
+
max_attempts?: number;
|
|
114
|
+
not_before?: number;
|
|
115
|
+
idempotency_key?: string | null;
|
|
116
|
+
callbacks?: JobLiveCallbacks;
|
|
117
|
+
}): Promise<JobDoc> {
|
|
118
|
+
const job = await createJob({
|
|
119
|
+
lane: input.lane,
|
|
120
|
+
type: input.type,
|
|
121
|
+
payload: { ...input.payload, _callbacks: !!input.callbacks },
|
|
122
|
+
run_id: input.run_id,
|
|
123
|
+
priority: input.priority,
|
|
124
|
+
max_attempts: input.max_attempts,
|
|
125
|
+
not_before: input.not_before,
|
|
126
|
+
idempotency_key: input.idempotency_key,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (input.callbacks) {
|
|
130
|
+
liveCallbacks.set(job.id, input.callbacks);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
this.scheduleDispatch(input.lane);
|
|
134
|
+
return job;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Cancel a job by id (pending or running). A running job also gets its AbortSignal fired. */
|
|
138
|
+
async cancel(jobId: string): Promise<boolean> {
|
|
139
|
+
const running = this.runningAborts.get(jobId);
|
|
140
|
+
if (running) running.controller.abort();
|
|
141
|
+
return cancelJob(jobId);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Cancel all pending and running jobs in a lane. */
|
|
145
|
+
async cancelLane(lane: string): Promise<number> {
|
|
146
|
+
let count = 0;
|
|
147
|
+
for (const [jobId, entry] of this.runningAborts) {
|
|
148
|
+
if (entry.lane === lane) {
|
|
149
|
+
entry.controller.abort();
|
|
150
|
+
if (await cancelJob(jobId)) count++;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const pending = await findPendingJobsByLane(lane, 100);
|
|
154
|
+
for (const job of pending) {
|
|
155
|
+
if (await cancelJob(job.id)) count++;
|
|
156
|
+
}
|
|
157
|
+
return count;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Start the maintenance tick (expired-lease reclaim + due-pending
|
|
162
|
+
* redispatch) and dispatch any jobs left pending by a previous boot.
|
|
163
|
+
*/
|
|
164
|
+
start(): void {
|
|
165
|
+
if (this.leaseCheckTimer) return;
|
|
166
|
+
this.leaseCheckTimer = setInterval(() => this.runMaintenanceTick(), LEASE_CHECK_INTERVAL_MS);
|
|
167
|
+
log.info(`[start] Maintenance tick running every ${LEASE_CHECK_INTERVAL_MS}ms`);
|
|
168
|
+
this.dispatchPendingLanes().catch((err) => {
|
|
169
|
+
log.error(`[start] Failed to dispatch pending lanes: ${(err as Error).message}`);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Periodic tick: reclaim crashed jobs (expired leases) AND re-dispatch
|
|
175
|
+
* lanes with jobs whose `not_before` (e.g. a backoff delay from
|
|
176
|
+
* `failJobOrRetry`) has now elapsed.
|
|
177
|
+
*/
|
|
178
|
+
private async runMaintenanceTick(): Promise<void> {
|
|
179
|
+
await this.checkExpiredLeases();
|
|
180
|
+
await this.dispatchPendingLanes().catch((err) => {
|
|
181
|
+
log.error(`[runMaintenanceTick] Failed to dispatch pending lanes: ${(err as Error).message}`);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private async dispatchPendingLanes(): Promise<void> {
|
|
186
|
+
const pending = await findAllPendingJobs();
|
|
187
|
+
const lanes = new Set(pending.map((j) => j.lane));
|
|
188
|
+
for (const lane of lanes) this.scheduleDispatch(lane);
|
|
189
|
+
if (lanes.size > 0) {
|
|
190
|
+
log.info(`[dispatchPendingLanes] Re-dispatching ${pending.length} pending job(s) across ${lanes.size} lane(s)`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
stop(): void {
|
|
195
|
+
if (this.leaseCheckTimer) {
|
|
196
|
+
clearInterval(this.leaseCheckTimer);
|
|
197
|
+
this.leaseCheckTimer = null;
|
|
198
|
+
}
|
|
199
|
+
for (const [, timer] of this.dispatchTimers) clearInterval(timer);
|
|
200
|
+
this.dispatchTimers.clear();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private scheduleDispatch(lane: string): void {
|
|
204
|
+
if (this.dispatchTimers.has(lane)) return;
|
|
205
|
+
const timer = setTimeout(() => {
|
|
206
|
+
this.dispatchTimers.delete(lane);
|
|
207
|
+
this.dispatchLane(lane).catch((err) => {
|
|
208
|
+
log.error(`[scheduleDispatch] Error dispatching lane ${lane}: ${(err as Error).message}`);
|
|
209
|
+
});
|
|
210
|
+
}, 0);
|
|
211
|
+
this.dispatchTimers.set(lane, timer);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private async dispatchLane(lane: string): Promise<void> {
|
|
215
|
+
for (;;) {
|
|
216
|
+
const pending = await findPendingJobsByLane(lane, 1);
|
|
217
|
+
if (pending.length === 0) break;
|
|
218
|
+
|
|
219
|
+
const job = pending[0];
|
|
220
|
+
// Interactive/user-facing types bypass the global cap so a busy batch
|
|
221
|
+
// of background jobs can't starve them — matches `hive`'s chat_turn
|
|
222
|
+
// convention: nonRetryableTypes doubles as the "must stay responsive" set.
|
|
223
|
+
if (!this.nonRetryableTypes.has(job.type) && this.runningCount >= this.maxGlobalConcurrency) break;
|
|
224
|
+
|
|
225
|
+
const claimed = await claimJob(job.id, this.bootId);
|
|
226
|
+
if (!claimed) continue;
|
|
227
|
+
|
|
228
|
+
this.runningCount++;
|
|
229
|
+
this.executeJob(claimed, lane).catch((err) => {
|
|
230
|
+
log.error(`[dispatchLane] Unhandled error in job ${claimed.id}: ${(err as Error).message}`);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private async executeJob(job: JobDoc, lane: string): Promise<void> {
|
|
238
|
+
const abortController = new AbortController();
|
|
239
|
+
this.runningAborts.set(job.id, { lane, controller: abortController });
|
|
240
|
+
const timeoutId = setTimeout(() => abortController.abort(), this.taskTimeoutMs);
|
|
241
|
+
|
|
242
|
+
let leasedHere = true;
|
|
243
|
+
const leaseRenewer = setInterval(async () => {
|
|
244
|
+
if (leasedHere) {
|
|
245
|
+
const { renewLease } = await import("./job-store");
|
|
246
|
+
await renewLease(job.id, this.bootId).catch(() => {});
|
|
247
|
+
}
|
|
248
|
+
}, 30_000);
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const executor = executors.get(job.type);
|
|
252
|
+
if (!executor) {
|
|
253
|
+
await failJob(job.id, `No executor registered for type=${job.type}`, this.bootId);
|
|
254
|
+
log.error(`[executeJob] No executor for type=${job.type} (job ${job.id})`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const callbacks = liveCallbacks.get(job.id);
|
|
259
|
+
const result = await executor(job, abortController.signal, callbacks);
|
|
260
|
+
liveCallbacks.delete(job.id);
|
|
261
|
+
|
|
262
|
+
if (result.ok) {
|
|
263
|
+
await completeJob(job.id, result.result ?? null, this.bootId);
|
|
264
|
+
} else {
|
|
265
|
+
const error = result.error ?? "Unknown error";
|
|
266
|
+
if (!this.nonRetryableTypes.has(job.type) && result.retryable !== false) {
|
|
267
|
+
await failJobOrRetry(job.id, error, this.bootId, this.jobRetryPolicy);
|
|
268
|
+
} else {
|
|
269
|
+
await failJob(job.id, error, this.bootId);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
} catch (err) {
|
|
273
|
+
if ((err as Error).name === "AbortError") {
|
|
274
|
+
await cancelJob(job.id);
|
|
275
|
+
} else {
|
|
276
|
+
const error = (err as Error).message;
|
|
277
|
+
if (!this.nonRetryableTypes.has(job.type)) {
|
|
278
|
+
await failJobOrRetry(job.id, error, this.bootId, this.jobRetryPolicy);
|
|
279
|
+
} else {
|
|
280
|
+
await failJob(job.id, error, this.bootId);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
} finally {
|
|
284
|
+
clearTimeout(timeoutId);
|
|
285
|
+
clearInterval(leaseRenewer);
|
|
286
|
+
leasedHere = false;
|
|
287
|
+
this.runningAborts.delete(job.id);
|
|
288
|
+
this.runningCount--;
|
|
289
|
+
this.scheduleDispatch(lane);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private async checkExpiredLeases(): Promise<void> {
|
|
294
|
+
try {
|
|
295
|
+
const expired = await findExpiredLeases();
|
|
296
|
+
for (const job of expired) {
|
|
297
|
+
const result = await reclaimOrInterrupt(job.id);
|
|
298
|
+
if (result?.status === "pending") {
|
|
299
|
+
this.scheduleDispatch(job.lane);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
} catch (err) {
|
|
303
|
+
log.warn(`[checkExpiredLeases] Error: ${(err as Error).message}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
getRunningCount(): number {
|
|
308
|
+
return this.runningCount;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
getMaxGlobalConcurrency(): number {
|
|
312
|
+
return this.maxGlobalConcurrency;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
getJobRetryPolicy(): JobRetryPolicy {
|
|
316
|
+
return this.jobRetryPolicy;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Live callback stash — not serializable, kept in memory only
|
|
321
|
+
const liveCallbacks = new Map<string, JobLiveCallbacks>();
|
|
322
|
+
|
|
323
|
+
// Singleton
|
|
324
|
+
let _durableQueue: DurableLaneQueue | null = null;
|
|
325
|
+
|
|
326
|
+
export function getDurableQueue(): DurableLaneQueue {
|
|
327
|
+
if (!_durableQueue) {
|
|
328
|
+
_durableQueue = new DurableLaneQueue();
|
|
329
|
+
}
|
|
330
|
+
return _durableQueue;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function initDurableQueue(options?: DurableLaneQueueOptions): DurableLaneQueue {
|
|
334
|
+
_durableQueue = new DurableLaneQueue(options);
|
|
335
|
+
_durableQueue.start();
|
|
336
|
+
return _durableQueue;
|
|
337
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* goal-verifier — verify whether a goal/acceptance-criterion has been met.
|
|
3
|
+
* Ported from `hive`'s agent/goal-runner.ts#verifyGoal, decoupled from
|
|
4
|
+
* `hive`'s goal-run orchestration loop (that's host-app specific — see
|
|
5
|
+
* `harness/collections.ts` doc comment). This module only answers "was it
|
|
6
|
+
* met", using either a caller-provided deterministic check tool or an LLM
|
|
7
|
+
* verifier.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { logger } from "../utils/logger";
|
|
11
|
+
import { callLLM, type LLMMessage, type LLMCallOptions } from "../agent/providers/LLMClient";
|
|
12
|
+
import type { AcceptanceCriterion } from "./run-store";
|
|
13
|
+
|
|
14
|
+
type ProviderConfig = Pick<LLMCallOptions, "provider" | "model" | "apiKey" | "baseUrl" | "numCtx" | "numGpu">;
|
|
15
|
+
|
|
16
|
+
const log = logger.child("harness:goal-verifier");
|
|
17
|
+
|
|
18
|
+
export interface AcceptanceResult {
|
|
19
|
+
id: string;
|
|
20
|
+
description: string;
|
|
21
|
+
met: boolean;
|
|
22
|
+
evidence: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface GoalVerdict {
|
|
26
|
+
met: boolean;
|
|
27
|
+
reason: string;
|
|
28
|
+
acceptanceResults?: AcceptanceResult[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Runs a caller-provided check tool and interprets its result (boolean, or
|
|
33
|
+
* an object/string with a `met` field). The harness has no built-in tool
|
|
34
|
+
* registry — the host app resolves `checkTool` to a callable.
|
|
35
|
+
*/
|
|
36
|
+
export type CheckToolRunner = (checkTool: string, args: { goal: string }) => Promise<unknown>;
|
|
37
|
+
|
|
38
|
+
export interface VerifyGoalOptions {
|
|
39
|
+
goal: string;
|
|
40
|
+
checkTool?: string | null;
|
|
41
|
+
messages: LLMMessage[];
|
|
42
|
+
providerCfg: ProviderConfig;
|
|
43
|
+
/** Required when any criterion (or the top-level goal) specifies `checkTool`. */
|
|
44
|
+
runCheckTool?: CheckToolRunner;
|
|
45
|
+
acceptance?: AcceptanceCriterion[] | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Verify whether a goal has been met using either a deterministic check
|
|
50
|
+
* tool (via `runCheckTool`) or an LLM verifier. When `acceptance` criteria
|
|
51
|
+
* are supplied, each is verified independently and the overall verdict is
|
|
52
|
+
* the conjunction of all of them — the top-level `goal`/`checkTool` are
|
|
53
|
+
* ignored in that case.
|
|
54
|
+
*/
|
|
55
|
+
export async function verifyGoal(opts: VerifyGoalOptions): Promise<GoalVerdict> {
|
|
56
|
+
const { goal, checkTool, messages, providerCfg, runCheckTool, acceptance } = opts;
|
|
57
|
+
|
|
58
|
+
if (acceptance && acceptance.length > 0) {
|
|
59
|
+
const results: AcceptanceResult[] = [];
|
|
60
|
+
for (const criterion of acceptance) {
|
|
61
|
+
const verdict = await verifyGoal({
|
|
62
|
+
goal: criterion.description,
|
|
63
|
+
checkTool: criterion.checkTool,
|
|
64
|
+
messages,
|
|
65
|
+
providerCfg,
|
|
66
|
+
runCheckTool,
|
|
67
|
+
});
|
|
68
|
+
results.push({ id: criterion.id, description: criterion.description, met: verdict.met, evidence: verdict.reason });
|
|
69
|
+
}
|
|
70
|
+
const met = results.every((r) => r.met);
|
|
71
|
+
const reason = results.map((r) => `${r.met ? "✅" : "❌"} ${r.description}: ${r.evidence}`).join("\n");
|
|
72
|
+
return { met, reason, acceptanceResults: results };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (checkTool) {
|
|
76
|
+
if (!runCheckTool) {
|
|
77
|
+
log.warn(`[verifyGoal] Check tool "${checkTool}" requested but no runCheckTool was provided — falling back to LLM verifier`);
|
|
78
|
+
} else {
|
|
79
|
+
try {
|
|
80
|
+
const result = await runCheckTool(checkTool, { goal });
|
|
81
|
+
return interpretCheckResult(result);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
log.warn(`[verifyGoal] Check tool "${checkTool}" failed: ${(err as Error).message}`);
|
|
84
|
+
// Fall through to LLM verifier
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const verificationMessages: LLMMessage[] = [
|
|
91
|
+
...messages,
|
|
92
|
+
{
|
|
93
|
+
role: "user",
|
|
94
|
+
content: `Evaluá si el siguiente objetivo ha sido cumplido basándote en la conversación anterior.\n\nObjetivo: "${goal}"\n\nRespondé en JSON:\n{"met": true/false, "reason": "explicación breve"}`,
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
const response = await callLLM({ ...providerCfg, messages: verificationMessages, tools: undefined });
|
|
99
|
+
|
|
100
|
+
const content = response.content?.trim() || "";
|
|
101
|
+
const jsonMatch = content.match(/\{[^}]*\}/);
|
|
102
|
+
if (jsonMatch) {
|
|
103
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
104
|
+
return { met: !!parsed.met, reason: parsed.reason || "No reason provided" };
|
|
105
|
+
}
|
|
106
|
+
return { met: false, reason: "Could not parse verification response" };
|
|
107
|
+
} catch (err) {
|
|
108
|
+
log.warn(`[verifyGoal] LLM verification failed: ${(err as Error).message}`);
|
|
109
|
+
return { met: false, reason: `Verification error: ${(err as Error).message}` };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Interpret a check tool's result strictly: an object with a boolean `met`,
|
|
115
|
+
* a bare boolean, or a JSON string with `met` — anything else is not met.
|
|
116
|
+
*/
|
|
117
|
+
function interpretCheckResult(raw: unknown): { met: boolean; reason: string } {
|
|
118
|
+
if (typeof raw === "boolean") {
|
|
119
|
+
return { met: raw, reason: raw ? "Check tool returned true" : "Check tool returned false" };
|
|
120
|
+
}
|
|
121
|
+
if (raw && typeof raw === "object" && "met" in (raw as Record<string, unknown>)) {
|
|
122
|
+
const obj = raw as { met: unknown; reason?: unknown };
|
|
123
|
+
return { met: obj.met === true, reason: typeof obj.reason === "string" ? obj.reason : `Check tool met=${obj.met === true}` };
|
|
124
|
+
}
|
|
125
|
+
if (typeof raw === "string") {
|
|
126
|
+
const trimmed = raw.trim();
|
|
127
|
+
if (trimmed === "true" || trimmed === "false") {
|
|
128
|
+
return { met: trimmed === "true", reason: `Check tool returned "${trimmed}"` };
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const parsed = JSON.parse(trimmed);
|
|
132
|
+
if (parsed && typeof parsed === "object" && "met" in parsed) {
|
|
133
|
+
return { met: parsed.met === true, reason: typeof parsed.reason === "string" ? parsed.reason : `Check tool met=${parsed.met === true}` };
|
|
134
|
+
}
|
|
135
|
+
if (typeof parsed === "boolean") {
|
|
136
|
+
return { met: parsed, reason: `Check tool returned ${parsed}` };
|
|
137
|
+
}
|
|
138
|
+
} catch { /* not JSON */ }
|
|
139
|
+
}
|
|
140
|
+
return { met: false, reason: "Check tool result had no interpretable met/true signal" };
|
|
141
|
+
}
|