@alecsibilia/luca 14.0.0-alpha.21 → 14.0.0-alpha.22

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.
@@ -1,6 +1,7 @@
1
1
  import type { MemoryAction, MemorySaveStep } from './decide-memory'
2
2
  import { buildContext, need, runTurn, type BuildDeps } from './execute-build'
3
3
 
4
+ import { roleEffort } from '../agents/role-effort'
4
5
  import { launchPick } from '../jev/model-pick'
5
6
  import type { Journal } from '../journal/journal'
6
7
  import type { JournalRecord } from '../journal/journal-record'
@@ -416,6 +417,10 @@ export const executeMemoryAction = async ({
416
417
  role: 'learner',
417
418
  step,
418
419
  })
420
+ const effort = roleEffort({
421
+ role: 'learner',
422
+ config: built.config,
423
+ })
419
424
  journal.append({
420
425
  kind: 'agent_started',
421
426
  ticket: null,
@@ -426,6 +431,7 @@ export const executeMemoryAction = async ({
426
431
  follow_up_of: null,
427
432
  model,
428
433
  model_reason,
434
+ effort,
429
435
  },
430
436
  })
431
437
  await runTurn({
@@ -445,6 +451,7 @@ export const executeMemoryAction = async ({
445
451
  // The learner writes nothing and talks to nobody.
446
452
  messaging: null,
447
453
  model,
454
+ effort,
448
455
  }),
449
456
  })
450
457
  return
@@ -6,6 +6,8 @@ import { z } from 'zod'
6
6
 
7
7
  import { ALLOW, deny, READ_TOOLS, type ToolDecision } from './guard-roles'
8
8
 
9
+ import type { EffortLevel } from '../config/engine-config'
10
+
9
11
  /** The SDK tool that starts a helper. */
10
12
  export const HELPER_TOOL = 'Agent'
11
13
 
@@ -15,6 +17,9 @@ export const HELPER_TYPE = 'searcher'
15
17
  /** Helpers always run on the newest Haiku, by its short name. */
16
18
  export const HELPER_MODEL = 'haiku'
17
19
 
20
+ /** Helpers always run at low effort (#659): a search needs little thought. */
21
+ export const HELPER_EFFORT: EffortLevel = 'low'
22
+
18
23
  /** The only tools a helper has: it reads and searches, nothing else. */
19
24
  export const HELPER_TOOLS = READ_TOOLS
20
25
 
@@ -26,6 +31,7 @@ export const SEARCHER = {
26
31
  description:
27
32
  'A read-only helper that searches the repo: finds files, symbols and callers, and reports what it found. It cannot edit files or run commands.',
28
33
  model: HELPER_MODEL,
34
+ effort: HELPER_EFFORT,
29
35
  tools: [...HELPER_TOOLS],
30
36
  prompt: 'You are a read-only searcher. You may only read and search files in the current worktree with Read, Grep and Glob. Never try to change a file or run a command. Answer with what you found: file paths, line numbers and short excerpts, as briefly as the question allows.',
31
37
  }
package/engine/index.ts CHANGED
@@ -15,7 +15,6 @@ export {
15
15
  type AgentQuerySession,
16
16
  } from './agents/claude-launcher'
17
17
  export {
18
- AGENT_EFFORT,
19
18
  agentEnv,
20
19
  agentOptions,
21
20
  BANNED_ENV,
@@ -32,6 +31,7 @@ export {
32
31
  LUCA_SERVER,
33
32
  sendMessageDescription,
34
33
  } from './agents/message-tool'
34
+ export { REVIEW_EFFORT, ROLE_EFFORT, roleEffort } from './agents/role-effort'
35
35
  export { roleInstructions } from './agents/role-instructions'
36
36
  export {
37
37
  rolePrompt,
@@ -10,8 +10,13 @@ import {
10
10
  } from './jev-schemas'
11
11
  import { offeredModels, type RoutedRole } from './model-pick'
12
12
 
13
+ import { ROLE_EFFORT, roleEffort } from '../agents/role-effort'
13
14
  import type { Finding } from '../agents/role-results'
14
- import { DEFAULT_MODEL, modelsOf } from '../config/engine-config'
15
+ import {
16
+ DEFAULT_MODEL,
17
+ modelsOf,
18
+ type EffortLevel,
19
+ } from '../config/engine-config'
15
20
  import type { EngineAction } from '../core/decide'
16
21
  import type { TicketSnapshot } from '../intake/intake-schemas'
17
22
  import type { JournalRecord } from '../journal/journal-record'
@@ -210,6 +215,51 @@ const agentModelAsk = ({
210
215
  }
211
216
  }
212
217
 
218
+ /** The efforts an `agent_effort` ask offers, lowest first. */
219
+ const OFFERED_EFFORTS: readonly EffortLevel[] = ['medium', 'high', 'xhigh']
220
+
221
+ /**
222
+ * Which effort a fresh test-writer or implementer on a ticket should use
223
+ * (#661), with the same facts as its `agent_model` ask. Shadow mode only:
224
+ * the agent always runs at its role's effort.
225
+ */
226
+ const agentEffortAsk = ({
227
+ state,
228
+ ticket,
229
+ role,
230
+ attempt,
231
+ }: {
232
+ state: RunState
233
+ ticket: number
234
+ role: RoutedRole
235
+ attempt: number
236
+ }): JevAsk => ({
237
+ job: 'agent_effort',
238
+ ticket,
239
+ role,
240
+ request: {
241
+ state: {
242
+ ...ticketState({ state, ticket }),
243
+ role,
244
+ try: String(attempt),
245
+ },
246
+ questions: {
247
+ effort: choice({
248
+ instructions:
249
+ 'Which effort should this agent use? Pick the lowest that will get it right.',
250
+ options: OFFERED_EFFORTS,
251
+ }),
252
+ },
253
+ },
254
+ fixed: {
255
+ effort:
256
+ state.config === null
257
+ ? ROLE_EFFORT[role]
258
+ : roleEffort({ role, config: state.config }),
259
+ },
260
+ acted: false,
261
+ })
262
+
213
263
  const agentSkillsAsk = ({
214
264
  state,
215
265
  ticket,
@@ -250,8 +300,9 @@ const isRoutedTicketRole = (role: string): role is RoutedRole =>
250
300
 
251
301
  /**
252
302
  * The asks to make before the engine carries out `action`: ticket order
253
- * before a ticket starts, skills before each agent, and the model before
254
- * each fresh test-writer, implementer, and learner.
303
+ * before a ticket starts, skills before each agent, the model before each
304
+ * fresh test-writer, implementer, and learner, and the effort before each
305
+ * fresh test-writer and implementer.
255
306
  */
256
307
  export const jevAsksBefore = ({
257
308
  action,
@@ -265,16 +316,12 @@ export const jevAsksBefore = ({
265
316
  return createTicketWorktreeAsks({ state, ticket: action.ticket })
266
317
  case 'launch_agent': {
267
318
  const { ticket, role } = action
268
- const failed = state.tickets[ticket]?.failed_tries[role] ?? 0
319
+ const attempt = (state.tickets[ticket]?.failed_tries[role] ?? 0) + 1
269
320
  return [
270
321
  ...(isRoutedTicketRole(role)
271
322
  ? [
272
- agentModelAsk({
273
- state,
274
- ticket,
275
- role,
276
- attempt: failed + 1,
277
- }),
323
+ agentModelAsk({ state, ticket, role, attempt }),
324
+ agentEffortAsk({ state, ticket, role, attempt }),
278
325
  ]
279
326
  : []),
280
327
  agentSkillsAsk({ state, ticket, role }),
@@ -100,11 +100,13 @@ export type JevAnswer = z.infer<typeof JevAnswerSchema>
100
100
  /**
101
101
  * The jobs the engine asks Jev about. `ticket_model` (one model per ticket)
102
102
  * is no longer asked; `agent_model` (one per agent) replaced it in #602. It
103
- * stays so old journals read.
103
+ * stays so old journals read. `agent_effort` is only ever asked in shadow
104
+ * mode (#661).
104
105
  */
105
106
  export const JevJobSchema = z.enum([
106
107
  'ticket_model',
107
108
  'agent_model',
109
+ 'agent_effort',
108
110
  'agent_skills',
109
111
  'ticket_order',
110
112
  'failure_kind',
@@ -8,6 +8,7 @@ import {
8
8
  FileModeSchema,
9
9
  RoleResultSchema,
10
10
  } from '../agents/role-results'
11
+ import { EffortLevelSchema } from '../config/engine-config'
11
12
  import { GateCheckSchema } from '../gates/gate-schemas'
12
13
  import { ModelReasonSchema } from '../jev/model-pick'
13
14
 
@@ -23,7 +24,9 @@ import { ModelReasonSchema } from '../jev/model-pick'
23
24
  * follow-up's is its session's); `null` in journals from before #600. The
24
25
  * exact version is in the turn's `agent_session`. `model_reason` is why it
25
26
  * launched on that model (#603; a follow-up's is its session's); `null` in
26
- * journals from before it.
27
+ * journals from before it. `effort` is the effort it ran at (#659; a
28
+ * follow-up's is its session's); `null` in journals from before it, when
29
+ * every agent ran at `high`.
27
30
  */
28
31
  export const AgentStartedEntrySchema = z.object({
29
32
  ...ENTRY_FIELDS,
@@ -34,6 +37,7 @@ export const AgentStartedEntrySchema = z.object({
34
37
  follow_up_of: z.string().nullable().default(null),
35
38
  model: z.string().nullable().default(null),
36
39
  model_reason: ModelReasonSchema.nullable().default(null),
40
+ effort: EffortLevelSchema.nullable().default(null),
37
41
  }),
38
42
  })
39
43
 
@@ -0,0 +1,273 @@
1
+ import {
2
+ EFFORT_BEFORE_659,
3
+ FINDING_WEIGHTS,
4
+ SCORED_ROLES,
5
+ type ScoredRole,
6
+ } from './model-score-rules'
7
+
8
+ import type { FindingSeverity } from '../agents/role-results'
9
+ import { modelsOf } from '../config/config-fields'
10
+ import {
11
+ EffortLevelSchema,
12
+ ModelNameSchema,
13
+ ModelsConfigSchema,
14
+ type EffortLevel,
15
+ type ModelName,
16
+ } from '../config/engine-config'
17
+ import type { JournalRecord } from '../journal/journal-record'
18
+ import { countedTokens } from '../limits/plan-usage'
19
+
20
+ /**
21
+ * What one run's journal says for the model score report (#605): each
22
+ * ticket's models, efforts, Clef's picks and review results, the learner's
23
+ * agents, and the final review's findings. Pure: it only reads records.
24
+ */
25
+
26
+ /** Clef's first model pick for a role on a ticket. */
27
+ export type Pick = {
28
+ model: ModelName | null
29
+ confidence: number | null
30
+ /** Asked in shadow mode: `acted: false`, or an older `ticket_model` ask. */
31
+ shadow: boolean
32
+ }
33
+
34
+ /** What one run's journal says about one ticket. */
35
+ export type TicketFacts = {
36
+ /** The model of each role's first agent. */
37
+ models: Partial<Record<ScoredRole, ModelName>>
38
+ /** The effort of each role's first agent, an older one's `high`. */
39
+ efforts: Partial<Record<ScoredRole, EffortLevel>>
40
+ /** The first ticket review's effort, `high` when none is journaled. */
41
+ review_effort: EffortLevel
42
+ picks: Partial<Record<ScoredRole, Pick>>
43
+ /**
44
+ * Clef's first `agent_effort` answer for each role at or above the run's
45
+ * confidence bar.
46
+ */
47
+ effort_picks: Partial<Record<ScoredRole, EffortLevel>>
48
+ reviews: number
49
+ /** The first ticket review's weighted findings. */
50
+ findings: number
51
+ fix_rounds: number
52
+ joined: boolean
53
+ lines: number | null
54
+ stuck: boolean
55
+ tokens: number | null
56
+ }
57
+
58
+ /** A fresh learner agent's model and effort. */
59
+ export type LearnerRun = { model: ModelName; effort: EffortLevel }
60
+
61
+ /** What one run's journal says. */
62
+ export type RunFacts = {
63
+ tickets: TicketFacts[]
64
+ routed: boolean
65
+ learner: LearnerRun[]
66
+ /** The first final review round's weighted findings, or `null` with none. */
67
+ final_findings: number | null
68
+ /** The joined tickets' lines, or `null` when one has none. */
69
+ lines: number | null
70
+ }
71
+
72
+ /**
73
+ * The model family a short name or full model id (as older `ticket_model`
74
+ * asks named them) belongs to, or `null` for none.
75
+ *
76
+ * @example
77
+ * familyOf('sonnet') // 'sonnet'
78
+ */
79
+ const familyOf = (name: unknown): ModelName | null => {
80
+ if (typeof name !== 'string') return null
81
+ const lower = name.toLowerCase()
82
+ return (
83
+ ModelNameSchema.options.find((model) => lower.includes(model)) ?? null
84
+ )
85
+ }
86
+
87
+ const isScored = (role: string | null): role is ScoredRole =>
88
+ SCORED_ROLES.some((scored) => scored === role)
89
+
90
+ export const sum = (values: number[]): number =>
91
+ values.reduce((total, value) => total + value, 0)
92
+
93
+ const weigh = (counts: Record<FindingSeverity, number>): number =>
94
+ counts.blocker * FINDING_WEIGHTS.blocker +
95
+ counts.should_fix * FINDING_WEIGHTS.should_fix +
96
+ counts.nit * FINDING_WEIGHTS.nit
97
+
98
+ const newTicket = (): TicketFacts => ({
99
+ models: {},
100
+ efforts: {},
101
+ review_effort: EFFORT_BEFORE_659,
102
+ picks: {},
103
+ effort_picks: {},
104
+ reviews: 0,
105
+ findings: 0,
106
+ fix_rounds: 0,
107
+ joined: false,
108
+ lines: null,
109
+ stuck: false,
110
+ tokens: null,
111
+ })
112
+
113
+ /**
114
+ * What one run's records say, ticket by ticket. An agent with no model ran
115
+ * Opus (before #600), and one with no effort ran at `high` (before #659).
116
+ */
117
+ export const factsOf = (records: JournalRecord[]): RunFacts => {
118
+ const tickets = new Map<number, TicketFacts>()
119
+ const ticketOf = (number: number): TicketFacts => {
120
+ const found = tickets.get(number) ?? newTicket()
121
+ tickets.set(number, found)
122
+ return found
123
+ }
124
+ const asks = new Map<number, { role: string | null; shadow: boolean }>()
125
+ const effort_asks = new Map<number, string | null>()
126
+ const reviewed = new Set<number>()
127
+ const learner: LearnerRun[] = []
128
+ let bar = ModelsConfigSchema.parse({}).confidence_bar
129
+ let routed = false
130
+ let final_rounds = 0
131
+ let final_findings = 0
132
+ for (const record of records) {
133
+ switch (record.kind) {
134
+ case 'run_started':
135
+ bar = modelsOf({ config: record.content.config }).confidence_bar
136
+ break
137
+ case 'jev_asked':
138
+ if (record.content.job === 'agent_effort') {
139
+ effort_asks.set(record.seq, record.role)
140
+ }
141
+ if (
142
+ record.content.job === 'agent_model' ||
143
+ record.content.job === 'ticket_model'
144
+ ) {
145
+ asks.set(record.seq, {
146
+ role: record.role,
147
+ shadow:
148
+ record.content.job === 'ticket_model' ||
149
+ !record.content.acted,
150
+ })
151
+ }
152
+ break
153
+ case 'jev_answered': {
154
+ const effort_role = effort_asks.get(record.content.asked_seq)
155
+ if (effort_role !== undefined && record.ticket !== null) {
156
+ const answer = record.content.answers.effort
157
+ const level = EffortLevelSchema.safeParse(answer?.value)
158
+ const confidence = answer?.confidence ?? null
159
+ if (
160
+ isScored(effort_role) &&
161
+ level.success &&
162
+ confidence !== null &&
163
+ confidence >= bar
164
+ ) {
165
+ ticketOf(record.ticket).effort_picks[effort_role] ??=
166
+ level.data
167
+ }
168
+ break
169
+ }
170
+ const ask = asks.get(record.content.asked_seq)
171
+ if (ask === undefined || record.ticket === null) break
172
+ const answer = record.content.answers.model
173
+ const pick: Pick = {
174
+ model: familyOf(answer?.value),
175
+ confidence: answer?.confidence ?? null,
176
+ shadow: ask.shadow,
177
+ }
178
+ const roles =
179
+ record.content.job === 'ticket_model'
180
+ ? SCORED_ROLES
181
+ : SCORED_ROLES.filter((role) => role === ask.role)
182
+ const facts = ticketOf(record.ticket)
183
+ for (const role of roles) facts.picks[role] ??= pick
184
+ break
185
+ }
186
+ case 'agent_started': {
187
+ const { role, model, effort, follow_up_of } = record.content
188
+ const family = familyOf(model) ?? 'opus'
189
+ const level = effort ?? EFFORT_BEFORE_659
190
+ if (follow_up_of !== null) break
191
+ if (role === 'learner') {
192
+ learner.push({ model: family, effort: level })
193
+ }
194
+ if (isScored(role) || role === 'learner') {
195
+ routed ||= family !== 'opus'
196
+ }
197
+ if (record.ticket === null) break
198
+ if (isScored(role)) {
199
+ const facts = ticketOf(record.ticket)
200
+ facts.models[role] ??= family
201
+ facts.efforts[role] ??= level
202
+ }
203
+ if (
204
+ role === 'ticket-reviewer' &&
205
+ !reviewed.has(record.ticket)
206
+ ) {
207
+ reviewed.add(record.ticket)
208
+ ticketOf(record.ticket).review_effort = level
209
+ }
210
+ break
211
+ }
212
+ case 'agent_finished': {
213
+ const { content } = record
214
+ if (
215
+ content.role !== 'ticket-reviewer' ||
216
+ record.ticket === null
217
+ ) {
218
+ break
219
+ }
220
+ const facts = ticketOf(record.ticket)
221
+ if (facts.reviews === 0) {
222
+ facts.findings = sum(
223
+ content.result.findings.map(
224
+ ({ severity }) => FINDING_WEIGHTS[severity]
225
+ )
226
+ )
227
+ }
228
+ facts.reviews += 1
229
+ if (content.result.verdict === 'changes_requested') {
230
+ facts.fix_rounds += 1
231
+ }
232
+ break
233
+ }
234
+ case 'ticket_joined':
235
+ if (record.ticket !== null && record.content.ok) {
236
+ const facts = ticketOf(record.ticket)
237
+ facts.joined = true
238
+ facts.lines = record.content.lines_changed
239
+ }
240
+ break
241
+ case 'ticket_stuck':
242
+ if (record.ticket !== null) ticketOf(record.ticket).stuck = true
243
+ break
244
+ case 'usage_recorded': {
245
+ const { scope, ticket, tokens } = record.content
246
+ if (scope === 'ticket' && ticket !== null) {
247
+ ticketOf(ticket).tokens = countedTokens({ tokens })
248
+ }
249
+ break
250
+ }
251
+ case 'final_review_started':
252
+ final_rounds += 1
253
+ break
254
+ case 'lens_finished':
255
+ if (final_rounds === 1) {
256
+ final_findings += weigh(record.content.findings)
257
+ }
258
+ break
259
+ default:
260
+ break
261
+ }
262
+ }
263
+ const joined = [...tickets.values()].filter(({ joined }) => joined)
264
+ return {
265
+ tickets: [...tickets.values()],
266
+ routed,
267
+ learner,
268
+ final_findings: final_rounds > 0 ? final_findings : null,
269
+ lines: joined.some(({ lines }) => lines === null)
270
+ ? null
271
+ : sum(joined.map(({ lines }) => lines ?? 0)),
272
+ }
273
+ }
@@ -0,0 +1,35 @@
1
+ import type { FindingSeverity } from '../agents/role-results'
2
+ import type { EffortLevel } from '../config/engine-config'
3
+
4
+ /**
5
+ * The model score report's constants (#605, #662): the finding weights, the
6
+ * verdict edges, the scored roles, and the effort an older journal's agent
7
+ * ran at. The one place they live.
8
+ */
9
+
10
+ /** How much each ticket review finding weighs. */
11
+ export const FINDING_WEIGHTS: Record<FindingSeverity, number> = {
12
+ blocker: 3,
13
+ should_fix: 1,
14
+ nit: 0,
15
+ }
16
+
17
+ /** The fewest tickets on each side before a verdict is given. */
18
+ export const MIN_TICKETS = 10
19
+
20
+ /** How far above the baseline a rate may be before it is "worse". */
21
+ export const WORSE_BY = 0.2
22
+
23
+ /** Rounding slack when a rate is compared with the baseline's. */
24
+ export const EPSILON = 1e-9
25
+
26
+ /** The roles scored by the ticket review's findings. */
27
+ export const SCORED_ROLES = ['test-writer', 'implementer'] as const
28
+
29
+ export type ScoredRole = (typeof SCORED_ROLES)[number]
30
+
31
+ /**
32
+ * The effort an agent with no journaled effort ran at: every role ran at
33
+ * `high` before #659.
34
+ */
35
+ export const EFFORT_BEFORE_659: EffortLevel = 'high'