@jitsusama/agentic-harness.core 0.4.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.
@@ -52,6 +52,8 @@ CREATE TABLE IF NOT EXISTS tool_calls (
52
52
  CREATE INDEX IF NOT EXISTS tool_calls_verifier ON tool_calls (verifier_kind);
53
53
  CREATE INDEX IF NOT EXISTS tool_calls_args ON tool_calls (session_id, args_digest);
54
54
  CREATE INDEX IF NOT EXISTS tool_calls_path ON tool_calls (path);
55
+ CREATE INDEX IF NOT EXISTS tool_calls_session_verifier
56
+ ON tool_calls (session_id, timestamp) WHERE verifier_kind IS NOT NULL;
55
57
  CREATE TABLE IF NOT EXISTS dropped_calls (
56
58
  digest TEXT PRIMARY KEY,
57
59
  session_id TEXT NOT NULL,
@@ -301,18 +303,32 @@ class SqliteTurnStore {
301
303
  const changed = writtenBetween("o", "o.previous_at", "o.timestamp", scope.writers);
302
304
  const rows = await this.db.all(`WITH ordered AS (
303
305
  SELECT c.session_id, c.args_digest, c.name, c.path,
304
- c.timestamp, c.result_chars,
306
+ c.timestamp, c.result_chars, c.verifier_kind,
305
307
  LAG(c.timestamp) OVER (
306
308
  PARTITION BY c.session_id, c.args_digest
307
309
  ORDER BY c.timestamp, c.digest
308
310
  ) AS previous_at
309
311
  FROM tool_calls AS c
310
312
  WHERE ${retrieval.sql}
313
+ ),
314
+ repeats AS (
315
+ SELECT o.session_id, o.args_digest, o.name, o.result_chars,
316
+ (o.verifier_kind IS NOT NULL OR EXISTS (
317
+ SELECT 1 FROM tool_calls AS v
318
+ WHERE v.session_id = o.session_id
319
+ AND v.verifier_kind IS NOT NULL
320
+ AND v.timestamp > o.previous_at
321
+ AND v.timestamp < o.timestamp
322
+ )) AS is_appraisal
323
+ FROM ordered AS o
324
+ WHERE o.previous_at IS NOT NULL AND NOT (${changed.sql})
311
325
  )
312
326
  SELECT args_digest, name, COUNT(*) AS repeated,
313
- SUM(COALESCE(result_chars, 0)) AS repeated_chars
314
- FROM ordered AS o
315
- WHERE o.previous_at IS NOT NULL AND NOT (${changed.sql})
327
+ SUM(COALESCE(result_chars, 0)) AS repeated_chars,
328
+ SUM(is_appraisal) AS appraisal,
329
+ SUM(CASE WHEN is_appraisal THEN COALESCE(result_chars, 0) ELSE 0 END)
330
+ AS appraisal_chars
331
+ FROM repeats
316
332
  GROUP BY session_id, args_digest
317
333
  ORDER BY repeated_chars DESC`, [...retrieval.params, ...changed.params]);
318
334
  return rows.map((row) => ({
@@ -321,6 +337,10 @@ class SqliteTurnStore {
321
337
  asked: row.repeated + 1,
322
338
  repeated: row.repeated,
323
339
  repeatedChars: row.repeated_chars ?? 0,
340
+ rework: row.repeated - row.appraisal,
341
+ reworkChars: (row.repeated_chars ?? 0) - (row.appraisal_chars ?? 0),
342
+ appraisal: row.appraisal,
343
+ appraisalChars: row.appraisal_chars ?? 0,
324
344
  }));
325
345
  }
326
346
  async recordDropped(dropped) {
@@ -181,6 +181,18 @@ export interface RepeatedCall {
181
181
  readonly repeated: number;
182
182
  /** Characters the repeats re-admitted to the context. */
183
183
  readonly repeatedChars: number;
184
+ /** Repeats with no verifier since the last ask: the waste bucket. */
185
+ readonly rework: number;
186
+ /** Characters the rework repeats re-admitted. */
187
+ readonly reworkChars: number;
188
+ /**
189
+ * Repeats that are themselves a verifier, or that follow one run
190
+ * since the last ask: the model checking its work, which is cost of
191
+ * quality rather than waste.
192
+ */
193
+ readonly appraisal: number;
194
+ /** Characters the appraisal repeats re-admitted. */
195
+ readonly appraisalChars: number;
184
196
  }
185
197
  /**
186
198
  * What a session log says about itself.
@@ -13,9 +13,16 @@ export interface RunRecordInput {
13
13
  readonly kind: string;
14
14
  readonly model: string;
15
15
  readonly persona: string;
16
+ /** The thinking level the run was launched at, null when unknown. */
17
+ readonly thinkingLevel: string | null;
16
18
  readonly startedAt: number;
17
19
  readonly result: {
18
20
  readonly exitCode: number;
21
+ /**
22
+ * The session ids the run's child processes announced, in order.
23
+ * Empty when there was no child process, null when not known.
24
+ */
25
+ readonly sessionIds: readonly string[] | null;
19
26
  readonly warnings: readonly string[];
20
27
  readonly usage?: {
21
28
  readonly tokens: RunTokens;
@@ -34,6 +34,8 @@ export function runRecordFrom(input) {
34
34
  tokens: result.usage?.tokens ?? null,
35
35
  cost: result.usage?.cost ?? null,
36
36
  startedAt: input.startedAt,
37
+ thinkingLevel: input.thinkingLevel,
38
+ subagentSessionIds: result.sessionIds,
37
39
  };
38
40
  }
39
41
  // Process-global so a producer extension's recordRunEverywhere
@@ -77,7 +77,7 @@ async function migrate(db) {
77
77
  // a later metered run cannot be reclassified.
78
78
  await db.exec("UPDATE runs SET metered = 0 WHERE tokens_total = 0 AND cost_total = 0");
79
79
  }
80
- for (const [name, type] of ATTRIBUTION_COLUMNS) {
80
+ for (const [name, type] of [...ATTRIBUTION_COLUMNS, ...LAUNCH_COLUMNS]) {
81
81
  if (!columns.has(name)) {
82
82
  await db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
83
83
  }
@@ -110,6 +110,15 @@ const ATTRIBUTION_COLUMNS = [
110
110
  ["repo", "TEXT"],
111
111
  ["ended_at", "INTEGER"],
112
112
  ];
113
+ /**
114
+ * How a run was launched, nullable for the same reason: rows written
115
+ * before these existed never said.
116
+ */
117
+ const LAUNCH_COLUMNS = [
118
+ ["thinking_level", "TEXT"],
119
+ // A JSON array of session ids; null when not known.
120
+ ["subagent_session_ids", "TEXT"],
121
+ ];
113
122
  class SqliteRunStore {
114
123
  db;
115
124
  constructor(db) {
@@ -121,8 +130,9 @@ class SqliteRunStore {
121
130
  retries_to_valid, warning_count, exit_code,
122
131
  tokens_input, tokens_output, tokens_cache_read, tokens_cache_write, tokens_total,
123
132
  cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total,
124
- started_at, metered, session_id, cwd, repo, ended_at
125
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
133
+ started_at, metered, session_id, cwd, repo, ended_at,
134
+ thinking_level, subagent_session_ids
135
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
126
136
  ON CONFLICT (run_id, subagent_id) DO UPDATE SET
127
137
  kind = excluded.kind, model = excluded.model,
128
138
  persona = excluded.persona, verify_outcome = excluded.verify_outcome,
@@ -141,7 +151,9 @@ class SqliteRunStore {
141
151
  cost_total = excluded.cost_total,
142
152
  started_at = excluded.started_at, metered = excluded.metered,
143
153
  session_id = excluded.session_id, cwd = excluded.cwd,
144
- repo = excluded.repo, ended_at = excluded.ended_at`, [
154
+ repo = excluded.repo, ended_at = excluded.ended_at,
155
+ thinking_level = excluded.thinking_level,
156
+ subagent_session_ids = excluded.subagent_session_ids`, [
145
157
  record.runId,
146
158
  record.subagentId,
147
159
  record.kind,
@@ -170,6 +182,10 @@ class SqliteRunStore {
170
182
  record.cwd ?? null,
171
183
  record.repo ?? null,
172
184
  record.endedAt ?? null,
185
+ record.thinkingLevel ?? null,
186
+ record.subagentSessionIds
187
+ ? JSON.stringify(record.subagentSessionIds)
188
+ : null,
173
189
  ]);
174
190
  }
175
191
  async queryRuns(filter = {}) {
@@ -272,6 +288,27 @@ class SqliteRunStore {
272
288
  await this.db.close();
273
289
  }
274
290
  }
291
+ /**
292
+ * Read the stored session list back. Anything that is not an array of
293
+ * strings reads as unknown rather than as a partial list, since a list
294
+ * with a session missing would join a job to less of its bill without
295
+ * saying so.
296
+ */
297
+ function sessionIdsFrom(stored) {
298
+ if (stored === null)
299
+ return null;
300
+ try {
301
+ const parsed = JSON.parse(stored);
302
+ return Array.isArray(parsed) &&
303
+ parsed.every((id) => typeof id === "string")
304
+ ? parsed
305
+ : null;
306
+ }
307
+ catch {
308
+ // Not JSON, so not something this store wrote: unknown.
309
+ return null;
310
+ }
311
+ }
275
312
  function rowToRecord(row) {
276
313
  return {
277
314
  runId: row.run_id,
@@ -306,5 +343,7 @@ function rowToRecord(row) {
306
343
  cwd: row.cwd ?? null,
307
344
  repo: row.repo ?? null,
308
345
  endedAt: row.ended_at ?? null,
346
+ thinkingLevel: row.thinking_level ?? null,
347
+ subagentSessionIds: sessionIdsFrom(row.subagent_session_ids),
309
348
  };
310
349
  }
@@ -59,6 +59,22 @@ export interface RunRecord {
59
59
  readonly cost: RunCost | null;
60
60
  /** When the run started, epoch milliseconds. */
61
61
  readonly startedAt: number;
62
+ /**
63
+ * The thinking level the run was launched at, or null when the
64
+ * launch did not name one and the level it inherited is not known.
65
+ * Required so a producer has to say, since a level left out reads
66
+ * the same as one nobody knew.
67
+ */
68
+ readonly thinkingLevel: string | null;
69
+ /**
70
+ * Every pi session the run's own processes announced, in order,
71
+ * which are the ids billing rows carry. Usually one; a stopped
72
+ * reviewer asked for its findings is a second process with a second
73
+ * session. Distinct from {@link sessionId}, the parent that
74
+ * dispatched the run. Empty when the run had no child process, as an
75
+ * in-process run does not; null when nobody knows.
76
+ */
77
+ readonly subagentSessionIds: readonly string[] | null;
62
78
  /**
63
79
  * The parent session that dispatched the run, the directory it was
64
80
  * working in and the repo that directory belongs to. Stamped by the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jitsusama/agentic-harness.core",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Pi-agnostic business logic for agentic-harness: state machines, guardian decisions, quest/TDD domain model.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,11 +0,0 @@
1
- import type { LedgerScan } from "./types.js";
2
- /**
3
- * Read billable turns out of a session log's lines.
4
- *
5
- * Every line is offered to the parser and every outcome is counted, so a
6
- * malformed line costs one entry rather than the remainder of the file.
7
- * Both places a turn can carry usage are read: assistant turns hold it
8
- * under `message`, and compactions hold it at the top level beside
9
- * `type`.
10
- */
11
- export declare function readTurns(sessionId: string, lines: Iterable<string>): LedgerScan;
@@ -1,159 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import { SessionCollector } from "./session.js";
3
- /** Width of a stored content address. 96 bits is ample for a corpus of
4
- * a few million turns and keeps the index small. */
5
- const DIGEST_CHARS = 24;
6
- /**
7
- * Read billable turns out of a session log's lines.
8
- *
9
- * Every line is offered to the parser and every outcome is counted, so a
10
- * malformed line costs one entry rather than the remainder of the file.
11
- * Both places a turn can carry usage are read: assistant turns hold it
12
- * under `message`, and compactions hold it at the top level beside
13
- * `type`.
14
- */
15
- export function readTurns(sessionId, lines) {
16
- const turns = [];
17
- const session = new SessionCollector(sessionId);
18
- let count = 0;
19
- let parsed = 0;
20
- let unparseable = 0;
21
- let billable = 0;
22
- let unmetered = 0;
23
- for (const line of lines) {
24
- count += 1;
25
- if (!line.trim())
26
- continue;
27
- let entry;
28
- try {
29
- const value = JSON.parse(line);
30
- if (typeof value !== "object" || value === null) {
31
- unparseable += 1;
32
- continue;
33
- }
34
- entry = value;
35
- }
36
- catch {
37
- // A truncated or interleaved write. Counted, never fatal: one
38
- // unreadable line once reduced a corpus-wide total to a tenth
39
- // of the truth by aborting the pipeline that met it.
40
- unparseable += 1;
41
- continue;
42
- }
43
- parsed += 1;
44
- if (entry.type === "session") {
45
- session.observeHeader(entry);
46
- continue;
47
- }
48
- if (entry.customType === "quest-workflow") {
49
- const data = asRecord(entry.data);
50
- if (data)
51
- session.observeWorkflow(data);
52
- continue;
53
- }
54
- const turn = turnFrom(sessionId, entry);
55
- if (!turn)
56
- continue;
57
- session.observeTurn(turn.timestamp);
58
- turns.push(turn);
59
- if (turn.cost)
60
- billable += 1;
61
- else
62
- unmetered += 1;
63
- }
64
- return {
65
- turns,
66
- coverage: { lines: count, parsed, unparseable, billable, unmetered },
67
- session: session.record(),
68
- };
69
- }
70
- function turnFrom(sessionId, entry) {
71
- const kind = kindOf(entry);
72
- if (!kind)
73
- return null;
74
- const message = asRecord(entry.message);
75
- const usage = asRecord(kind === "compaction" ? entry.usage : message?.usage);
76
- const entryId = typeof entry.id === "string" ? entry.id : "";
77
- const timestamp = typeof entry.timestamp === "string" ? entry.timestamp : "";
78
- const model = typeof message?.model === "string" ? message.model : "";
79
- return {
80
- entryId,
81
- sessionId,
82
- timestamp,
83
- kind,
84
- model,
85
- tokens: tokensFrom(usage),
86
- cost: costFrom(usage),
87
- cacheWrite1h: usage?.cacheWrite1h ?? 0,
88
- droppedBefore: kind === "compaction" && typeof entry.tokensBefore === "number"
89
- ? entry.tokensBefore
90
- : null,
91
- firstKeptEntryId: typeof entry.firstKeptEntryId === "string"
92
- ? entry.firstKeptEntryId
93
- : null,
94
- digest: digestOf(entryId, timestamp, kind, usage),
95
- };
96
- }
97
- /**
98
- * Which turns are billable at all. An assistant turn and a compaction
99
- * both cost money; a user message, a tool result and a state change do
100
- * not. Enumerated rather than filtered, so a new entry type is ignored
101
- * by omission instead of silently swept into a total.
102
- */
103
- function kindOf(entry) {
104
- if (entry.type === "compaction")
105
- return "compaction";
106
- const message = asRecord(entry.message);
107
- if (message?.role === "assistant")
108
- return "assistant";
109
- return null;
110
- }
111
- function tokensFrom(usage) {
112
- const input = usage?.input ?? 0;
113
- const output = usage?.output ?? 0;
114
- const cacheRead = usage?.cacheRead ?? 0;
115
- const cacheWrite = usage?.cacheWrite ?? 0;
116
- return {
117
- input,
118
- output,
119
- cacheRead,
120
- cacheWrite,
121
- total: usage?.totalTokens ?? input + output + cacheRead + cacheWrite,
122
- };
123
- }
124
- /** Null when the entry reported no cost, which is not the same as free. */
125
- function costFrom(usage) {
126
- const cost = usage?.cost;
127
- if (!cost || typeof cost.total !== "number")
128
- return null;
129
- return {
130
- input: cost.input ?? 0,
131
- output: cost.output ?? 0,
132
- cacheRead: cost.cacheRead ?? 0,
133
- cacheWrite: cost.cacheWrite ?? 0,
134
- total: cost.total,
135
- };
136
- }
137
- /**
138
- * Address a turn by what it is rather than where it was found. Forking a
139
- * session copies entries verbatim, ids included, so the same turn appears
140
- * in several files and a naive count bills it more than once.
141
- */
142
- function digestOf(entryId, timestamp, kind, usage) {
143
- const canonical = JSON.stringify([
144
- entryId,
145
- timestamp,
146
- kind,
147
- usage?.cost?.total ?? null,
148
- usage?.totalTokens ?? null,
149
- ]);
150
- return createHash("sha256")
151
- .update(canonical)
152
- .digest("hex")
153
- .slice(0, DIGEST_CHARS);
154
- }
155
- function asRecord(value) {
156
- return typeof value === "object" && value !== null
157
- ? value
158
- : null;
159
- }
@@ -1,38 +0,0 @@
1
- import type { SessionRecord } from "./types.js";
2
- /**
3
- * Name the repo a working directory belongs to, or nothing when the path
4
- * names none.
5
- *
6
- * A monorepo zone is named by the zone rather than the worktree it was
7
- * cut into, because two trees of the same zone are the same subject and
8
- * naming the tree would split one zone's spend across every tree ever
9
- * cut for it.
10
- */
11
- export declare function repoOf(cwd: string | null): string | null;
12
- /**
13
- * Accumulates what a log says about its session as the log is read, so
14
- * one pass serves both the turns and their attribution.
15
- */
16
- export declare class SessionCollector {
17
- private readonly sessionId;
18
- private cwd;
19
- private quest;
20
- private first;
21
- private last;
22
- constructor(sessionId: string);
23
- /**
24
- * Take the working directory from a session's header entry. Every log
25
- * opens with one, which is what makes attribution complete rather than
26
- * limited to the quarter of sessions that also name a quest.
27
- */
28
- observeHeader(entry: Record<string, unknown>): void;
29
- /**
30
- * Take the working directory and quest a workflow entry names. The
31
- * last one wins, because a session can be re-pointed at another quest
32
- * part way through and the later statement is the current one.
33
- */
34
- observeWorkflow(data: Record<string, unknown>): void;
35
- /** Widen the span to include a billed turn. */
36
- observeTurn(timestamp: string): void;
37
- record(): SessionRecord;
38
- }
@@ -1,89 +0,0 @@
1
- /** Where a per-host checkout tree begins, as `src/{host}/{owner}/{repo}`. */
2
- const CHECKOUT_MARKER = "/src/";
3
- /** Where a monorepo worktree begins, as `world/trees/{tree}/src/{zone}`. */
4
- const MONOREPO_MARKER = "/world/trees/";
5
- /** Segments naming a repo under the checkout marker: host, owner, name. */
6
- const REPO_SEGMENTS = 3;
7
- /**
8
- * Name the repo a working directory belongs to, or nothing when the path
9
- * names none.
10
- *
11
- * A monorepo zone is named by the zone rather than the worktree it was
12
- * cut into, because two trees of the same zone are the same subject and
13
- * naming the tree would split one zone's spend across every tree ever
14
- * cut for it.
15
- */
16
- export function repoOf(cwd) {
17
- if (!cwd)
18
- return null;
19
- const monorepo = cwd.indexOf(MONOREPO_MARKER);
20
- if (monorepo >= 0) {
21
- const tail = cwd.slice(monorepo + MONOREPO_MARKER.length);
22
- const zone = tail.split("/src/")[1];
23
- return zone ? `world/${zone}` : null;
24
- }
25
- const checkout = cwd.indexOf(CHECKOUT_MARKER);
26
- if (checkout >= 0) {
27
- const parts = cwd
28
- .slice(checkout + CHECKOUT_MARKER.length)
29
- .split("/")
30
- .filter(Boolean);
31
- if (parts.length >= REPO_SEGMENTS) {
32
- return parts.slice(0, REPO_SEGMENTS).join("/");
33
- }
34
- }
35
- return null;
36
- }
37
- /**
38
- * Accumulates what a log says about its session as the log is read, so
39
- * one pass serves both the turns and their attribution.
40
- */
41
- export class SessionCollector {
42
- sessionId;
43
- cwd = null;
44
- quest = null;
45
- first = null;
46
- last = null;
47
- constructor(sessionId) {
48
- this.sessionId = sessionId;
49
- }
50
- /**
51
- * Take the working directory from a session's header entry. Every log
52
- * opens with one, which is what makes attribution complete rather than
53
- * limited to the quarter of sessions that also name a quest.
54
- */
55
- observeHeader(entry) {
56
- if (typeof entry.cwd === "string")
57
- this.cwd = entry.cwd;
58
- }
59
- /**
60
- * Take the working directory and quest a workflow entry names. The
61
- * last one wins, because a session can be re-pointed at another quest
62
- * part way through and the later statement is the current one.
63
- */
64
- observeWorkflow(data) {
65
- if (typeof data.cwd === "string")
66
- this.cwd = data.cwd;
67
- if (typeof data.questId === "string")
68
- this.quest = data.questId;
69
- }
70
- /** Widen the span to include a billed turn. */
71
- observeTurn(timestamp) {
72
- if (!timestamp)
73
- return;
74
- if (!this.first || timestamp < this.first)
75
- this.first = timestamp;
76
- if (!this.last || timestamp > this.last)
77
- this.last = timestamp;
78
- }
79
- record() {
80
- return {
81
- sessionId: this.sessionId,
82
- cwd: this.cwd,
83
- repo: repoOf(this.cwd),
84
- quest: this.quest,
85
- firstSeen: this.first,
86
- lastSeen: this.last,
87
- };
88
- }
89
- }