@gaia-ai/core 0.4.3 → 0.4.4

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.
@@ -3,7 +3,7 @@ export { CommandRunner, ExecError, exec, setDefaultCommandRunner, } from './core
3
3
  export { type ConductorLogger, createLogger } from './core/logger.js';
4
4
  export { shellQuote } from './core/shell.js';
5
5
  export { DEFAULT_SLUG_MAX_LENGTH, slugify } from './core/slug.js';
6
- export type { GaiaAgent } from './plugins/agent/agent.js';
6
+ export { type AgentFootprint, emptyAgentFootprint, type GaiaAgent, } from './plugins/agent/agent.js';
7
7
  export type * from './plugins/executor/executor.js';
8
8
  export type { AgentPlugin, ExecutorDeps, ExecutorPlugin, RemotePlugin, WorkspacePlugin, } from './plugins/plugins.js';
9
9
  export type * from './plugins/remote/remote.js';
package/dist/src/index.js CHANGED
@@ -3,4 +3,5 @@ export { CommandRunner, ExecError, exec, setDefaultCommandRunner, } from './core
3
3
  export { createLogger } from './core/logger.js';
4
4
  export { shellQuote } from './core/shell.js';
5
5
  export { DEFAULT_SLUG_MAX_LENGTH, slugify } from './core/slug.js';
6
+ export { emptyAgentFootprint, } from './plugins/agent/agent.js';
6
7
  export { loadInstructions } from './plugins/workspace/instructions.js';
@@ -1,3 +1,27 @@
1
+ /**
2
+ * Per-agent effort footprint parsed from that agent's run transcript
3
+ * (GAIA-132). The metrics are transcript-derived and therefore
4
+ * **agent-specific** (each agent's transcript has its own format), so parsing
5
+ * lives behind the agent abstraction (see {@link GaiaAgent.parseFootprint}) —
6
+ * the conductor stays agent-agnostic and only adds `duration_s` (wall-clock,
7
+ * agent-independent) before writing the run's footprint.
8
+ */
9
+ export interface AgentFootprint {
10
+ /** Total tokens across every usage bucket of every assistant turn. */
11
+ tokens: number;
12
+ /** Number of assistant turns in the transcript. */
13
+ agent_turns: number;
14
+ /** Number of tool-use calls across all assistant turns. */
15
+ tool_calls: number;
16
+ /** Number of user-submitted prompts. */
17
+ user_prompts: number;
18
+ /** Total words across those user prompts. */
19
+ user_prompt_words: number;
20
+ /** Model of the last assistant turn, when present (informational). */
21
+ model?: string;
22
+ }
23
+ /** An all-zero footprint — the honest result for an empty/absent transcript. */
24
+ export declare function emptyAgentFootprint(): AgentFootprint;
1
25
  /**
2
26
  * A GAIA agent: the program the conductor runs to work a ticket (e.g. claude).
3
27
  * It knows how it is launched (CLI + model + flags) and where its per-run
@@ -17,4 +41,12 @@ export interface GaiaAgent {
17
41
  * `worktreePath`. MUST return '' (never throw) when nothing is found.
18
42
  */
19
43
  getRunLog(worktreePath: string): Promise<string>;
44
+ /**
45
+ * Parse this agent's run transcript (as returned by {@link getRunLog}) into
46
+ * a footprint of effort metrics (GAIA-132). The transcript format is
47
+ * agent-specific, so each agent owns its own parser. MUST be tolerant of
48
+ * blank/partial/absent input (never throw) — an empty log yields
49
+ * {@link emptyAgentFootprint}.
50
+ */
51
+ parseFootprint(log: string): AgentFootprint;
20
52
  }
@@ -1 +1,10 @@
1
- export {};
1
+ /** An all-zero footprint — the honest result for an empty/absent transcript. */
2
+ export function emptyAgentFootprint() {
3
+ return {
4
+ tokens: 0,
5
+ agent_turns: 0,
6
+ tool_calls: 0,
7
+ user_prompts: 0,
8
+ user_prompt_words: 0,
9
+ };
10
+ }
@@ -1,6 +1,6 @@
1
1
  import type { JsonApiClient } from 'dropsh/plugin';
2
2
  import type { RemotePlugin } from '../plugins.js';
3
- import type { ActiveRun, ClaimedRun, ClaimOptions, ConductorRegistration, ConductorStatus, FinalizableRun, GaiaRemote, RunWriteAttributes, Ticket, UncleanTicket } from './remote.js';
3
+ import type { ActiveRun, ClaimedRun, ClaimOptions, ConductorRegistration, ConductorStatus, FinalizableRun, GaiaRemote, RunMetrics, RunWriteAttributes, Ticket, UncleanTicket } from './remote.js';
4
4
  export declare class DrupalGaiaRemote implements GaiaRemote {
5
5
  private readonly api;
6
6
  constructor(api: JsonApiClient);
@@ -18,8 +18,8 @@ export declare class DrupalGaiaRemote implements GaiaRemote {
18
18
  listConductors(owner?: 'me'): Promise<ConductorStatus[]>;
19
19
  markRunning(uuid: string, attrs?: RunWriteAttributes): Promise<void>;
20
20
  fetchFinalizableRuns(id: string): Promise<FinalizableRun[]>;
21
- finalizeRun(uuid: string, log: string): Promise<void>;
22
- fetchUncleanedTickets(): Promise<UncleanTicket[]>;
21
+ finalizeRun(uuid: string, log: string, metrics?: RunMetrics): Promise<void>;
22
+ fetchUncleanedTickets(id: string): Promise<UncleanTicket[]>;
23
23
  closeTicket(uuid: string): Promise<void>;
24
24
  markTicketCleanedUp(uuid: string): Promise<void>;
25
25
  /**
@@ -204,35 +204,42 @@ export class DrupalGaiaRemote {
204
204
  .where('conductor_id.machine_id', '=', id)
205
205
  .where('state', '=', 'done')
206
206
  .where('closed', '=', '0')
207
- .fields(['worktree_path'])
207
+ .fields(['worktree_path', 'started_at'])
208
208
  .page(100)
209
209
  .list();
210
- return rows.map((r) => ({
211
- runUuid: r.id,
212
- worktreePath: r.attr('worktree_path') ?? '',
213
- }));
210
+ return rows.map((r) => {
211
+ const startedAt = r.attr('started_at');
212
+ return {
213
+ runUuid: r.id,
214
+ worktreePath: r.attr('worktree_path') ?? '',
215
+ ...(typeof startedAt === 'number' ? { startedAt } : {}),
216
+ };
217
+ });
214
218
  }
215
- async finalizeRun(uuid, log) {
219
+ async finalizeRun(uuid, log, metrics) {
216
220
  const t = Math.floor(Date.now() / 1000);
217
221
  await this.api.update('gaia_run', uuid, {
218
222
  attributes: {
219
223
  closed: true,
220
224
  closed_date: t,
221
225
  ...(log ? { log } : {}),
226
+ ...(metrics ?? {}),
222
227
  },
223
228
  }); // NO state — the run is already done.
224
229
  }
225
- async fetchUncleanedTickets() {
230
+ async fetchUncleanedTickets(id) {
226
231
  // Finished tickets whose worktree is not yet torn down (cleaned_up=0),
227
- // driven by the durable `cleaned_up` flag (GAIA-89). "Finished" = reached
228
- // `done` OR already `closed`; the collection builder has no OR, so this is
229
- // two AND-queries merged + de-duped by uuid. NOT machine-scoped, so the
230
- // same query backs both the tick and the standalone `gaia conductor reap`
231
- // and catches tickets orphaned while a conductor was down / after a restart
232
- // / after a `conductor_id` reassignment (RC2/RC3).
232
+ // driven by the durable `cleaned_up` flag (GAIA-89) AND scoped to this
233
+ // conductor (GAIA-121): only tickets assigned to `id` are loaded, so a
234
+ // ticket owned by another conductor never reaches the reaper loop. "Finished"
235
+ // = reached `done` OR already `closed`; the collection builder has no OR, so
236
+ // this is two AND-queries merged + de-duped by uuid. The `conductor_id`
237
+ // relationship carries the assigned conductor's `machine_id` the same
238
+ // nested filter the sibling reaper queries (`fetchFinalizableRuns` etc.) use.
233
239
  const fields = ['branch_name', 'state', 'closed'];
234
240
  const done = await this.api
235
241
  .collection('gaia_ticket')
242
+ .where('conductor_id.machine_id', '=', id)
236
243
  .where('state', '=', 'done')
237
244
  .where('cleaned_up', '=', '0')
238
245
  .fields(fields)
@@ -240,6 +247,7 @@ export class DrupalGaiaRemote {
240
247
  .list();
241
248
  const closed = await this.api
242
249
  .collection('gaia_ticket')
250
+ .where('conductor_id.machine_id', '=', id)
243
251
  .where('closed', '=', '1')
244
252
  .where('cleaned_up', '=', '0')
245
253
  .fields(fields)
@@ -1,5 +1,5 @@
1
1
  import type { RemotePlugin } from '../plugins.js';
2
- import type { ActiveRun, ClaimedRun, ClaimOptions, ConductorRegistration, ConductorStatus, FinalizableRun, GaiaRemote, RunWriteAttributes, Ticket, UncleanTicket } from './remote.js';
2
+ import type { ActiveRun, ClaimedRun, ClaimOptions, ConductorRegistration, ConductorStatus, FinalizableRun, GaiaRemote, RunMetrics, RunWriteAttributes, Ticket, UncleanTicket } from './remote.js';
3
3
  export interface FakeSeedRun {
4
4
  runUuid: string;
5
5
  /** Numeric (drupal_internal__id) run id. Defaults to a stable 1-based counter when omitted. */
@@ -11,6 +11,8 @@ export interface FakeSeedRun {
11
11
  handler?: string;
12
12
  worktreePath?: string;
13
13
  closed?: boolean;
14
+ /** Unix timestamp (seconds) the run started — drives footprint duration_s. */
15
+ startedAt?: number;
14
16
  }
15
17
  export interface FakeSeedTicket {
16
18
  identifier: string;
@@ -27,6 +29,12 @@ export interface FakeSeedTicket {
27
29
  closed?: boolean;
28
30
  /** Whether the ticket's worktree is already torn down (cleaned_up flag). */
29
31
  cleanedUp?: boolean;
32
+ /**
33
+ * machine_id of the conductor this ticket is assigned to (GAIA-121). Omit to
34
+ * mean "belongs to the querying conductor" (the common case for reaper tests);
35
+ * set an explicit value to test conductor-scoping, or `''` for unassigned.
36
+ */
37
+ conductorId?: string;
30
38
  }
31
39
  export interface FakeRemoteSeed {
32
40
  runs?: FakeSeedRun[];
@@ -41,6 +49,7 @@ export interface MarkRunningCall {
41
49
  export interface FinalizeRunCall {
42
50
  runUuid: string;
43
51
  log: string;
52
+ metrics?: RunMetrics;
44
53
  }
45
54
  export declare class FakeGaiaRemote implements GaiaRemote {
46
55
  readonly calls: {
@@ -75,8 +84,8 @@ export declare class FakeGaiaRemote implements GaiaRemote {
75
84
  getRunTicketBranchName(runUuid: string): Promise<string>;
76
85
  markRunning(runUuid: string, attrs?: RunWriteAttributes): Promise<void>;
77
86
  fetchFinalizableRuns(_conductorId: string): Promise<FinalizableRun[]>;
78
- finalizeRun(runUuid: string, log: string): Promise<void>;
79
- fetchUncleanedTickets(): Promise<UncleanTicket[]>;
87
+ finalizeRun(runUuid: string, log: string, metrics?: RunMetrics): Promise<void>;
88
+ fetchUncleanedTickets(conductorId: string): Promise<UncleanTicket[]>;
80
89
  closeTicket(uuid: string): Promise<void>;
81
90
  markTicketCleanedUp(uuid: string): Promise<void>;
82
91
  /** Worktree path of the ticket's most recently seeded run, or '' when none. */
@@ -33,6 +33,7 @@ export class FakeGaiaRemote {
33
33
  ...(r.worktreePath !== undefined
34
34
  ? { worktreePath: r.worktreePath }
35
35
  : {}),
36
+ ...(r.startedAt !== undefined ? { startedAt: r.startedAt } : {}),
36
37
  ...(r.closed !== undefined ? { closed: r.closed } : {}),
37
38
  });
38
39
  this.queue.push({
@@ -150,10 +151,18 @@ export class FakeGaiaRemote {
150
151
  async fetchFinalizableRuns(_conductorId) {
151
152
  return [...this.runs.values()]
152
153
  .filter((r) => r.state === 'done' && !r.closed)
153
- .map((r) => ({ runUuid: r.runUuid, worktreePath: r.worktreePath ?? '' }));
154
+ .map((r) => ({
155
+ runUuid: r.runUuid,
156
+ worktreePath: r.worktreePath ?? '',
157
+ ...(r.startedAt !== undefined ? { startedAt: r.startedAt } : {}),
158
+ }));
154
159
  }
155
- async finalizeRun(runUuid, log) {
156
- this.calls.finalizeRun.push({ runUuid, log });
160
+ async finalizeRun(runUuid, log, metrics) {
161
+ this.calls.finalizeRun.push({
162
+ runUuid,
163
+ log,
164
+ ...(metrics ? { metrics } : {}),
165
+ });
157
166
  const run = this.runs.get(runUuid);
158
167
  if (run) {
159
168
  run.closed = true;
@@ -161,9 +170,14 @@ export class FakeGaiaRemote {
161
170
  run.log = log;
162
171
  }
163
172
  }
164
- async fetchUncleanedTickets() {
173
+ async fetchUncleanedTickets(conductorId) {
174
+ // Scoped to the querying conductor (GAIA-121). A seed without an explicit
175
+ // `conductorId` defaults to the querying conductor (the common reaper-test
176
+ // case); an explicit value scopes it, and `''` models an unassigned ticket.
165
177
  return Object.entries(this.tickets)
166
- .filter(([, t]) => (t.state === 'done' || t.closed) && !t.cleanedUp)
178
+ .filter(([, t]) => (t.state === 'done' || t.closed) &&
179
+ !t.cleanedUp &&
180
+ (t.conductorId ?? conductorId) === conductorId)
167
181
  .map(([ticketUuid, t]) => ({
168
182
  ticketUuid,
169
183
  branchName: t.branchName ?? `gaia/${t.identifier.toLowerCase()}`,
@@ -57,6 +57,25 @@ export interface FinalizableRun {
57
57
  runUuid: string;
58
58
  /** Absolute path of the per-run git worktree, or '' when unset. */
59
59
  worktreePath: string;
60
+ /**
61
+ * Unix timestamp (seconds) the run started, or undefined when unset. The
62
+ * conductor derives the footprint `duration_s` as `now − startedAt` at close
63
+ * (GAIA-132).
64
+ */
65
+ startedAt?: number;
66
+ }
67
+ /**
68
+ * Per-run effort footprint the conductor writes onto `gaia_run` at close
69
+ * (GAIA-132). Six integer metrics: the five parsed from the agent transcript
70
+ * plus `duration_s` computed from `started_at → close`.
71
+ */
72
+ export interface RunMetrics {
73
+ tokens: number;
74
+ duration_s: number;
75
+ agent_turns: number;
76
+ tool_calls: number;
77
+ user_prompts: number;
78
+ user_prompt_words: number;
60
79
  }
61
80
  /**
62
81
  * A finished ticket whose worktree has not yet been torn down — the
@@ -123,19 +142,22 @@ export interface GaiaRemote {
123
142
  */
124
143
  fetchFinalizableRuns(conductorId: string): Promise<FinalizableRun[]>;
125
144
  /**
126
- * Finalise a run: set closed + closed_date and (when non-empty) log. No state
127
- * change the run is already done.
145
+ * Finalise a run: set closed + closed_date, (when non-empty) log, and (when
146
+ * given) the per-run footprint metrics (GAIA-132). No state change — the run
147
+ * is already done.
128
148
  */
129
- finalizeRun(uuid: string, log: string): Promise<void>;
149
+ finalizeRun(uuid: string, log: string, metrics?: RunMetrics): Promise<void>;
130
150
  /**
131
151
  * Finished tickets (state=done OR closed) whose worktree is not yet torn
132
152
  * down (cleaned_up=0) — the conductor's teardown work list (GAIA-89). Driven
133
- * by the durable `cleaned_up` flag, NOT machine-scoped: the same query backs
134
- * both the live tick and the standalone `gaia conductor reap`, so it catches
135
- * orphans a conductor missed while down / after a restart / after a
136
- * `conductor_id` reassignment. Empty once every finished ticket is cleaned.
153
+ * by the durable `cleaned_up` flag AND scoped to the reaping conductor
154
+ * (GAIA-121): only tickets assigned to `conductorId` are loaded, so every
155
+ * ticket on the list is unambiguously this conductor's a missing worktree
156
+ * means "already torn down on this host", not "belongs to another host". The
157
+ * same query backs the live tick and the standalone `gaia conductor reap`.
158
+ * Empty once every finished ticket this conductor owns is cleaned.
137
159
  */
138
- fetchUncleanedTickets(): Promise<UncleanTicket[]>;
160
+ fetchUncleanedTickets(conductorId: string): Promise<UncleanTicket[]>;
139
161
  /**
140
162
  * Close a ticket: set closed=true + closed_date. Ticket-lifecycle only, and
141
163
  * decoupled from teardown — a done ticket is closed even if its worktree
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaia-ai/core",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "GAIA global contract: plugin API, built-in remotes/workspaces/auth, shared primitives.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,7 +26,7 @@
26
26
  "directory": "conductor/packages/core"
27
27
  },
28
28
  "dependencies": {
29
- "dropsh": "^0.5.3",
29
+ "dropsh": "^0.5.6",
30
30
  "pino": "^9.6.0",
31
31
  "pino-pretty": "^13.0.0"
32
32
  }