@owlmeans/agent-common 0.1.18-rc.7 → 0.1.18

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.
Files changed (65) hide show
  1. package/README.md +174 -13
  2. package/agent-meta/manifest.json +3 -3
  3. package/agent-meta/skills/agent-common/SKILL.md +69 -14
  4. package/build/consts.d.ts +118 -5
  5. package/build/consts.d.ts.map +1 -1
  6. package/build/consts.js +204 -4
  7. package/build/consts.js.map +1 -1
  8. package/build/consts.local.d.ts +9 -0
  9. package/build/consts.local.d.ts.map +1 -0
  10. package/build/consts.local.js +9 -0
  11. package/build/consts.local.js.map +1 -0
  12. package/build/errors.d.ts +60 -0
  13. package/build/errors.d.ts.map +1 -1
  14. package/build/errors.js +84 -0
  15. package/build/errors.js.map +1 -1
  16. package/build/helpers/conversation.d.ts +0 -9
  17. package/build/helpers/conversation.d.ts.map +1 -1
  18. package/build/helpers/conversation.js +0 -28
  19. package/build/helpers/conversation.js.map +1 -1
  20. package/build/helpers/truncate.d.ts +10 -0
  21. package/build/helpers/truncate.d.ts.map +1 -0
  22. package/build/helpers/truncate.js +29 -0
  23. package/build/helpers/truncate.js.map +1 -0
  24. package/build/index.d.ts +6 -1
  25. package/build/index.d.ts.map +1 -1
  26. package/build/index.js +4 -1
  27. package/build/index.js.map +1 -1
  28. package/build/models/pipeline-spec/types.d.ts +57 -0
  29. package/build/models/pipeline-spec/types.d.ts.map +1 -0
  30. package/build/models/pipeline-spec/types.js +2 -0
  31. package/build/models/pipeline-spec/types.js.map +1 -0
  32. package/build/models/pipeline-spec.d.ts +4 -0
  33. package/build/models/pipeline-spec.d.ts.map +1 -0
  34. package/build/models/pipeline-spec.js +171 -0
  35. package/build/models/pipeline-spec.js.map +1 -0
  36. package/build/pipeline.d.ts +10 -0
  37. package/build/pipeline.d.ts.map +1 -0
  38. package/build/pipeline.js +10 -0
  39. package/build/pipeline.js.map +1 -0
  40. package/build/results/types.d.ts +211 -0
  41. package/build/results/types.d.ts.map +1 -0
  42. package/build/results/types.js +2 -0
  43. package/build/results/types.js.map +1 -0
  44. package/build/results.d.ts +4 -0
  45. package/build/results.d.ts.map +1 -0
  46. package/build/results.js +218 -0
  47. package/build/results.js.map +1 -0
  48. package/build/types.d.ts +146 -24
  49. package/build/types.d.ts.map +1 -1
  50. package/package.json +8 -8
  51. package/src/consts.local.ts +15 -0
  52. package/src/consts.ts +218 -4
  53. package/src/errors.ts +96 -0
  54. package/src/helpers/conversation.ts +0 -32
  55. package/src/helpers/truncate.ts +31 -0
  56. package/src/index.ts +6 -1
  57. package/src/models/pipeline-spec/types.ts +58 -0
  58. package/src/models/pipeline-spec.ts +202 -0
  59. package/src/pipeline.ts +16 -0
  60. package/src/results/types.ts +221 -0
  61. package/src/results.ts +270 -0
  62. package/src/types.ts +153 -25
  63. package/tests/pipeline.spec.ts +164 -0
  64. package/tests/results.spec.ts +148 -0
  65. package/src/flows.ts +0 -118
package/src/types.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ResourceRecord } from '@owlmeans/resource'
2
- import type { ExecutionState } from '@owlmeans/llm-common'
3
- import type { AgentRunStatus } from './consts.js'
2
+ import { type AgentRunStatus, PipelineRunStatus } from './consts.js'
3
+ import type { Inquiry } from '@owlmeans/llm-common'
4
4
 
5
5
  /**
6
6
  * What a conversation is, as an address.
@@ -43,21 +43,6 @@ export interface ConversationEventInput extends Omit<ConversationEvent, 'id' | '
43
43
  createdAt?: string
44
44
  }
45
45
 
46
- /**
47
- * The data plane of a run.
48
- *
49
- * `flow` is the serialized {@link import('@owlmeans/flow').FlowModel} state — the control plane
50
- * collapsed to a string — and `state` is the execution snapshot the LLM layer produces. Keeping
51
- * them in one record is what makes a resume a single read.
52
- */
53
- export interface AgentRunState extends ResourceRecord {
54
- id: string
55
- conversationId: string
56
- flow: string
57
- state: ExecutionState
58
- updatedAt: string
59
- }
60
-
61
46
  /**
62
47
  * A node of the subsystem memory graph.
63
48
  *
@@ -89,16 +74,159 @@ export interface MemoryEventInput extends Omit<MemoryEvent, 'id' | 'seq' | 'crea
89
74
  }
90
75
 
91
76
  /**
92
- * What a transport carries.
77
+ * What a transport carries: a POINTER, never a payload.
93
78
  *
94
- * The execution state travels by REFERENCE (`stateRef`), not by value: a project execution's state
95
- * holds the whole project specification, and a queue whose messages carry that is a queue that
96
- * falls over on the first large project. The flow string is small enough to inline, and it is what
97
- * a consumer needs to route the message before it reads anything.
79
+ * Everything a consumer needs to route the message is here, and everything else is read from the
80
+ * run row it names. That is not an optimization — a pipeline state is scalars and keys precisely so
81
+ * that the authority stays in one place, and a message that carried a copy would be a second one,
82
+ * stale from the moment it was enqueued.
98
83
  */
99
84
  export interface AgentRunMessage {
100
- id: string
85
+ runId: string
101
86
  conversationId: string
102
- flow: string
103
- stateRef?: string
87
+ /** The pipeline alias, when the message is about a pipeline run rather than a conversation. */
88
+ pipeline?: string
89
+ /** Where the run stood when the message was sent. Advisory — the row is authoritative. */
90
+ step?: string
91
+ }
92
+
93
+ /**
94
+ * What a pipeline carries between its steps.
95
+ *
96
+ * Scalars and KEYS — ids, revisions, markers, lists of codes. Never an artifact. Everything large a
97
+ * pipeline produces belongs in a store of its own and travels here as the key that finds it again:
98
+ * the state is serialized into the run row at every step boundary, and a state that carries a
99
+ * document is a row that carries it too, once per step.
100
+ */
101
+ // Kept as a type: every state shape must satisfy it without declaring an index signature.
102
+ export type PipelineState = Record<string, unknown>
103
+
104
+ export interface PipelineStepSpec {
105
+ step: string
106
+ /**
107
+ * The steps that must have completed before this one may run.
108
+ *
109
+ * Absent or empty means "from the start". Several steps naming the same predecessor fan OUT;
110
+ * one step naming several predecessors JOINS them. That is the whole edge vocabulary — there is
111
+ * no conditional edge, because a branch that is expressed as an edge is invisible to a resume,
112
+ * while a branch expressed as {@link PipelineStep.skipWhen} is the same mechanism that makes a
113
+ * resume correct.
114
+ */
115
+ after?: string[]
116
+ /** Human-readable, for progress reporting and tool descriptions. */
117
+ title?: string
118
+ /** A failure is recorded on the run's `warnings` and the successors still run. */
119
+ optional?: boolean
120
+ /**
121
+ * Re-running repeats a side effect that cannot be undone — a wipe, a purge, a claim against a
122
+ * rate-limited authority, a model call whose output is already on disk.
123
+ *
124
+ * A resume never re-enters one of these on its own; `resume(runId, { from })` refuses without
125
+ * `force`. It is also refused `attempts > 1`, since an automatic retry is exactly the thing the
126
+ * flag says must not happen.
127
+ */
128
+ nonIdempotent?: boolean
129
+ /**
130
+ * Attempts for ONE entry of this step. Default {@link DEFAULT_STEP_ATTEMPTS} = 1.
131
+ *
132
+ * Deliberately not a ladder: retry budgets in this family MULTIPLY (an outer 8 around a model's
133
+ * own inner 8 is 64 real calls), so a step that wants retries asks for them once, here, and the
134
+ * helpers below it keep theirs.
135
+ */
136
+ attempts?: number
137
+ /** Wall clock for one attempt, in milliseconds. */
138
+ timeout?: number
139
+ }
140
+
141
+ export interface PipelineSpec {
142
+ alias: string
143
+ /**
144
+ * Bumped whenever a step is added, removed or renamed.
145
+ *
146
+ * `completed` names steps by string, so a run resumed under a changed spec would skip work it
147
+ * never did — silently, because an unknown name simply matches nothing. A version mismatch is
148
+ * refused instead.
149
+ */
150
+ version: number
151
+ steps: PipelineStepSpec[]
152
+ }
153
+
154
+ /**
155
+ * What a `Waiting` run is waiting for.
156
+ *
157
+ * Written by the runner when a step asks a question nobody could answer while it ran, and read by
158
+ * everything that reports a run — a resume delivers the answer under {@link Inquiry.id}, which is
159
+ * the only thing that routes it back.
160
+ */
161
+ export interface PipelineRunInquiry {
162
+ /** The step that asked. A resume re-enters exactly this one. */
163
+ step: string
164
+ askedAt: string
165
+ inquiry: Inquiry
166
+ }
167
+
168
+ /**
169
+ * The data plane of a pipeline run, and the ONE authority on where it stands.
170
+ *
171
+ * Not the LangGraph checkpoint: that is a replay optimization which may legitimately be absent (it
172
+ * is size-guarded and expires), and a design that trusts it has a silent hole exactly where a
173
+ * crashed run needs an answer. Everything that reads a run's position — a resume, a reconciler, an
174
+ * operator, a status endpoint — reads this row.
175
+ *
176
+ * Timestamps are ISO strings, never `Date`: these records cross process boundaries and storage
177
+ * backends. A consumer whose store prefers dates maps them at its own adapter boundary.
178
+ */
179
+ export interface PipelineRun extends ResourceRecord {
180
+ /** The business key. Chosen by the caller so a run is addressable before it exists. */
181
+ runId: string
182
+ pipeline: string
183
+ version: number
184
+ /** The subject the run belongs to — for an application with projects, the project id. */
185
+ scope: string
186
+ /** The tenant. Carried so a store can refuse a read that crosses an organization. */
187
+ entityId?: string
188
+ status: PipelineRunStatus
189
+ /** Steps that have finished, in the order they finished. */
190
+ completed: string[]
191
+ /** Steps that had not run when the run stopped. Empty on a `Done` run. */
192
+ pending: string[]
193
+ /** JSON TEXT of {@link PipelineState}. Text, not a subdocument — a state key is a caller's word. */
194
+ state: string
195
+ stateChars: number
196
+ /** What an `optional` step reported when it failed. Never empties a run. */
197
+ warnings: string[]
198
+ failedAt?: string
199
+ error?: string
200
+ /** The last thing a step said about itself. Progress, not state. */
201
+ note?: string
202
+ /**
203
+ * The question this run stopped on.
204
+ *
205
+ * Present only while `status === Waiting`, and cleared on every entry of the run — a row that
206
+ * kept advertising a question it has already been given would have every reader offer it again.
207
+ */
208
+ inquiry?: PipelineRunInquiry
209
+ /** Whatever lock the run held, so a resume can re-take the same one. */
210
+ lockTask?: string
211
+ /** How many times this run has been RESUMED. Reset only by a `Done` outcome. */
212
+ attempts: number
213
+ startedAt: string
214
+ /**
215
+ * Written at every step boundary — the only signal that separates a run still working from one
216
+ * whose process died. A reconciler reads staleness here, never the status.
217
+ */
218
+ heartbeatAt: string
219
+ updatedAt: string
220
+ }
221
+
222
+ /** What the runner reports as it moves. Never persisted as state. */
223
+ export interface PipelineProgress {
224
+ pipeline: string
225
+ runId: string
226
+ step: string
227
+ /** 1-based position in the topological order. */
228
+ index: number
229
+ total: number
230
+ skipped?: boolean
231
+ note?: string
104
232
  }
@@ -0,0 +1,164 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { InquiryKind } from '@owlmeans/llm-common'
3
+ import { INQUIRY_ANSWERS_KEY, PipelineRunStatus, makePipelineSpecModel } from '../src/index.js'
4
+ import type { PipelineRun, PipelineSpec } from '../src/index.js'
5
+
6
+ /**
7
+ * The spec is the one thing in this family that is checked without a model, a slot or a network, so
8
+ * every fault it can carry is pinned here. A spec fault that reaches the runner is a build error a
9
+ * developer reads once; the same fault reaching a deployment is a pipeline that starts and then
10
+ * cannot say why it did nothing.
11
+ */
12
+
13
+ const spec = (steps: PipelineSpec['steps'], version = 1): PipelineSpec =>
14
+ ({ alias: 'test', version, steps })
15
+
16
+ describe('agent-common — pipeline spec validation', () => {
17
+ test('accepts a linear spec', () => {
18
+ expect(() => makePipelineSpecModel(spec([
19
+ { step: 'a' }, { step: 'b', after: ['a'] }, { step: 'c', after: ['b'] },
20
+ ])).validate()).not.toThrow()
21
+ })
22
+
23
+ test('refuses an empty step list, an empty alias and a bad version at once', () => {
24
+ expect(() => makePipelineSpecModel({ alias: '', version: 0, steps: [] }).validate())
25
+ .toThrow(/alias is empty.*version.*no steps/s)
26
+ })
27
+
28
+ test('names EVERY fault in one message rather than the first', () => {
29
+ let message = ''
30
+ try {
31
+ makePipelineSpecModel(spec([
32
+ { step: 'a' },
33
+ { step: 'a' },
34
+ { step: 'b', after: ['nope'] },
35
+ { step: 'c', nonIdempotent: true, attempts: 3 },
36
+ ])).validate()
37
+ } catch (e) {
38
+ message = (e as Error).message
39
+ }
40
+ expect(message).toContain('duplicate step "a"')
41
+ expect(message).toContain('unknown step "nope"')
42
+ expect(message).toContain('nonIdempotent')
43
+ })
44
+
45
+ test('refuses a step that waits on itself', () => {
46
+ expect(() => makePipelineSpecModel(spec([{ step: 'a', after: ['a'] }])).validate())
47
+ .toThrow(/declares itself/)
48
+ })
49
+
50
+ test('refuses a cycle', () => {
51
+ expect(() => makePipelineSpecModel(spec([
52
+ { step: 'a', after: ['c'] }, { step: 'b', after: ['a'] }, { step: 'c', after: ['b'] },
53
+ ])).validate()).toThrow(/cycle/)
54
+ })
55
+
56
+ test('refuses attempts below one and a non-positive timeout', () => {
57
+ expect(() => makePipelineSpecModel(spec([{ step: 'a', attempts: 0 }])).validate()).toThrow(/attempts/)
58
+ expect(() => makePipelineSpecModel(spec([{ step: 'a', timeout: 0 }])).validate()).toThrow(/timeout/)
59
+ })
60
+
61
+ test('allows attempts above one on an idempotent step', () => {
62
+ expect(() => makePipelineSpecModel(spec([{ step: 'a', attempts: 3 }])).validate()).not.toThrow()
63
+ })
64
+ })
65
+
66
+ describe('agent-common — pipeline ordering', () => {
67
+ test('respects `after` and keeps declaration order as the tie-break', () => {
68
+ // `c` and `b` are both ready after `a`; declaration order decides, not a set's iteration.
69
+ expect(makePipelineSpecModel(spec([
70
+ { step: 'a' }, { step: 'c', after: ['a'] }, { step: 'b', after: ['a'] },
71
+ ])).orderSteps()).toEqual(['a', 'c', 'b'])
72
+ })
73
+
74
+ test('orders a fan-out/join graph so the join comes last', () => {
75
+ const order = makePipelineSpecModel(spec([
76
+ { step: 'left' },
77
+ { step: 'right' },
78
+ { step: 'deeper', after: ['right'] },
79
+ { step: 'join', after: ['left', 'deeper'] },
80
+ ])).orderSteps()
81
+ expect(order.indexOf('join')).toBe(3)
82
+ expect(order.indexOf('deeper')).toBeGreaterThan(order.indexOf('right'))
83
+ })
84
+
85
+ test('is stable across calls', () => {
86
+ const declared = spec([
87
+ { step: 'a' }, { step: 'b' }, { step: 'c', after: ['a', 'b'] }, { step: 'd', after: ['a'] },
88
+ ])
89
+ expect(makePipelineSpecModel(declared).orderSteps()).toEqual(makePipelineSpecModel(declared).orderSteps())
90
+ })
91
+
92
+ test('throws on an unknown `after` and on a cycle', () => {
93
+ expect(() => makePipelineSpecModel(spec([{ step: 'a', after: ['ghost'] }])).orderSteps()).toThrow(/unknown step/)
94
+ expect(() => makePipelineSpecModel(spec([
95
+ { step: 'a', after: ['b'] }, { step: 'b', after: ['a'] },
96
+ ])).orderSteps()).toThrow(/cycle/)
97
+ })
98
+ })
99
+
100
+ describe('agent-common — descendants', () => {
101
+ const graph = spec([
102
+ { step: 'a' },
103
+ { step: 'b', after: ['a'] },
104
+ { step: 'c', after: ['a'] },
105
+ { step: 'd', after: ['b', 'c'] },
106
+ { step: 'aside' },
107
+ ])
108
+
109
+ test('a step and everything that transitively waits on it, in order', () => {
110
+ expect(makePipelineSpecModel(graph).descendants('b')).toEqual(['b', 'd'])
111
+ expect(makePipelineSpecModel(graph).descendants('a')).toEqual(['a', 'b', 'c', 'd'])
112
+ })
113
+
114
+ test('leaves an unrelated branch alone', () => {
115
+ expect(makePipelineSpecModel(graph).descendants('b')).not.toContain('aside')
116
+ })
117
+
118
+ test('refuses a step the spec does not declare', () => {
119
+ expect(() => makePipelineSpecModel(graph).descendants('ghost')).toThrow(/pipeline-step/)
120
+ })
121
+
122
+ test('pipelineStep answers null rather than throwing', () => {
123
+ expect(makePipelineSpecModel(graph).stepOf('ghost')).toBeNull()
124
+ expect(makePipelineSpecModel(graph).stepOf('a')?.step).toBe('a')
125
+ })
126
+ })
127
+
128
+ describe('agent-common — a run that is waiting for an answer', () => {
129
+ const waiting = (): PipelineRun => ({
130
+ runId: 'r', pipeline: 'test', version: 1, scope: 'p1',
131
+ status: PipelineRunStatus.Waiting,
132
+ completed: ['a'], pending: ['b'], state: `{"${INQUIRY_ANSWERS_KEY}":{}}`, stateChars: 15,
133
+ warnings: [], attempts: 0,
134
+ inquiry: {
135
+ step: 'b',
136
+ askedAt: '2026-01-01T00:00:00.000Z',
137
+ inquiry: {
138
+ id: 'q1', kind: InquiryKind.Choice, question: 'Which product is this?',
139
+ options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }],
140
+ },
141
+ },
142
+ startedAt: '2026-01-01T00:00:00.000Z',
143
+ heartbeatAt: '2026-01-01T00:00:00.000Z',
144
+ updatedAt: '2026-01-01T00:00:00.000Z',
145
+ })
146
+
147
+ test('`Waiting` is its own status, distinct from the two that mean the run stopped badly', () => {
148
+ // A reconciler branches on this string. Aborted means nobody asked anything and a resume may
149
+ // simply carry on; Waiting means a resume without an answer would park on the same question.
150
+ expect(PipelineRunStatus.Waiting).toBe('waiting')
151
+ expect(PipelineRunStatus.Waiting).not.toBe(PipelineRunStatus.Aborted)
152
+ expect(PipelineRunStatus.Waiting).not.toBe(PipelineRunStatus.Failed)
153
+ })
154
+
155
+ test('a row carrying its inquiry survives the wire unchanged', () => {
156
+ // These records cross process boundaries and storage backends; the question's id is the ONLY
157
+ // thing that routes an answer back, so it has to arrive spelled exactly as it was written.
158
+ const restored = JSON.parse(JSON.stringify(waiting())) as PipelineRun
159
+
160
+ expect(restored).toEqual(waiting())
161
+ expect(restored.inquiry?.inquiry.id).toBe('q1')
162
+ expect(restored.inquiry?.step).toBe('b')
163
+ })
164
+ })
@@ -0,0 +1,148 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ CumulativeFactKind, CumulativeResultSource, RESULTS_EVERY_STEP, cumulativeResultsHelper, makePipelineSpecModel,
4
+ } from '../src/index.js'
5
+ import type { CumulativeResultFact, PipelineSpec, RenderableResultEntry } from '../src/index.js'
6
+
7
+ /**
8
+ * The pure half of cumulative pipeline results. What a later step is told is rendered here, so
9
+ * every property a prompt depends on — the same facts give the same bytes, nothing varying leaks
10
+ * in, a cap drops whole facts and says so — is pinned without a runtime.
11
+ */
12
+
13
+ const diamond: PipelineSpec = {
14
+ alias: 'diamond',
15
+ version: 1,
16
+ steps: [
17
+ { step: 'root' },
18
+ { step: 'left', after: ['root'] },
19
+ { step: 'right', after: ['root'] },
20
+ { step: 'deeper', after: ['right'] },
21
+ { step: 'join', after: ['left', 'deeper'] },
22
+ { step: 'aside' },
23
+ ],
24
+ }
25
+
26
+ const user: CumulativeResultFact = {
27
+ kind: CumulativeFactKind.Type, name: 'User', signature: '{ id: string;\n email: string }',
28
+ specifier: '@app/common', path: 'sources/common/src/user.ts',
29
+ }
30
+ const create: CumulativeResultFact = {
31
+ kind: CumulativeFactKind.Endpoint, name: 'user.create', ref: 'User',
32
+ attrs: { path: '/api/users', method: 'POST' },
33
+ }
34
+ const file: CumulativeResultFact = { kind: CumulativeFactKind.File, name: 'sources/common/src/user.ts' }
35
+
36
+ const entry = (patch: Partial<RenderableResultEntry> = {}): RenderableResultEntry => ({
37
+ label: 'types', source: CumulativeResultSource.Extracted, facts: [user, create, file], ...patch,
38
+ })
39
+
40
+ describe('agent-common — pipeline ancestry', () => {
41
+ test('names every transitive predecessor in topological order, never the step itself', () => {
42
+ expect(makePipelineSpecModel(diamond).ancestors('join')).toEqual(['root', 'left', 'right', 'deeper'])
43
+ expect(makePipelineSpecModel(diamond).ancestors('root')).toEqual([])
44
+ // The mirror includes the named step; ancestry deliberately does not.
45
+ expect(makePipelineSpecModel(diamond).descendants('root')).toContain('root')
46
+ })
47
+
48
+ test('carries the SHORTEST distance, so a direct predecessor is depth 1', () => {
49
+ expect(makePipelineSpecModel(diamond).ancestry('join')).toEqual([
50
+ { step: 'root', depth: 2 },
51
+ { step: 'left', depth: 1 },
52
+ { step: 'right', depth: 2 },
53
+ { step: 'deeper', depth: 1 },
54
+ ])
55
+ })
56
+
57
+ test('refuses a step the spec does not declare', () => {
58
+ expect(() => makePipelineSpecModel(diamond).ancestors('ghost')).toThrow(/pipeline-step/)
59
+ })
60
+ })
61
+
62
+ describe('agent-common — rendering an entry', () => {
63
+ test('the same facts render the same bytes in whatever order they arrive', () => {
64
+ const one = cumulativeResultsHelper.renderResultEntry(entry())
65
+ const two = cumulativeResultsHelper.renderResultEntry(entry({ facts: [file, create, user] }))
66
+
67
+ expect(two).toEqual(one)
68
+ expect(one.full).toBe([
69
+ '### types',
70
+ '- endpoint `user.create` (method: POST, path: /api/users) → `User`',
71
+ '- type `User`: { id: string; email: string } — import from `@app/common` (file `sources/common/src/user.ts`)',
72
+ '- file `sources/common/src/user.ts`',
73
+ ].join('\n'))
74
+ })
75
+
76
+ test('the names-only form lists names by kind and says it is names only', () => {
77
+ expect(cumulativeResultsHelper.renderResultEntry(entry()).compact).toBe([
78
+ '### types (names only)',
79
+ '- endpoint: `user.create`',
80
+ '- type: `User`',
81
+ '- file: `sources/common/src/user.ts`',
82
+ ].join('\n'))
83
+ })
84
+
85
+ test('over its cap it drops whole facts, never part of one, and says how many', () => {
86
+ const { full } = cumulativeResultsHelper.renderResultEntry(entry(), { maxChars: 200 })
87
+
88
+ expect(full.length).toBeLessThanOrEqual(200)
89
+ expect(full).toContain('- endpoint `user.create`')
90
+ expect(full).not.toContain('- type `User`')
91
+ expect(full).toContain('and 2 more not listed here for space')
92
+ })
93
+
94
+ test('a summary is always labelled unverified, and a summary alone heads the entry so', () => {
95
+ const withFacts = cumulativeResultsHelper.renderResultEntry(entry({ summary: 'Chose soft deletes.' })).full
96
+ const alone = cumulativeResultsHelper.renderResultEntry(entry({
97
+ facts: [], summary: 'Chose soft deletes.', source: CumulativeResultSource.Summarized,
98
+ })).full
99
+
100
+ expect(withFacts).toContain('Summary (not verified — written by a model, never checked against the files): Chose soft deletes.')
101
+ expect(alone.split('\n')[0]).toBe('### types (not verified)')
102
+ })
103
+
104
+ test('a partial entry says the step failed, and an empty ordinary entry renders nothing', () => {
105
+ expect(cumulativeResultsHelper.renderResultEntry(entry({ partial: true })).full.split('\n')[0])
106
+ .toBe('### types (partial: the step failed part-way; only what it left in the files is listed)')
107
+ expect(cumulativeResultsHelper.renderResultEntry(entry({ facts: [] }))).toEqual({ full: '', compact: '' })
108
+ })
109
+ })
110
+
111
+ describe('agent-common — ledger helpers', () => {
112
+ test('a composed run sorts inside its composing step, ahead of that step\'s own entry', () => {
113
+ const keys = [[3], [1], [3, 2], [3, 1], [0], [4]]
114
+ expect([...keys].sort(cumulativeResultsHelper.compareResultOrder)).toEqual([[0], [1], [3, 1], [3, 2], [3], [4]])
115
+ })
116
+
117
+ test('labels a composed run\'s step by its path under the root run', () => {
118
+ expect(cumulativeResultsHelper.resultLabel('r', 'r', 'types')).toBe('types')
119
+ expect(cumulativeResultsHelper.resultLabel('r', 'r/design', 'screens')).toBe('design/screens')
120
+ expect(cumulativeResultsHelper.resultLabel('r', 'r/design/deep', 'x')).toBe('design/deep/x')
121
+ })
122
+
123
+ test('a fact is identified by kind, name and where it comes from', () => {
124
+ expect(cumulativeResultsHelper.factKey(user)).toBe(cumulativeResultsHelper.factKey({ ...user, signature: 'other' }))
125
+ expect(cumulativeResultsHelper.factKey(user)).not.toBe(cumulativeResultsHelper.factKey({ ...user, specifier: '@app/other' }))
126
+ })
127
+
128
+ test('a summary answer renders deterministically and is capped after the fact', () => {
129
+ expect(cumulativeResultsHelper.renderResultSummary({ summary: ' two\nlines ' })).toBe('two lines')
130
+ expect(cumulativeResultsHelper.renderResultSummary({ b: ['x', 'y'], a: 1 })).toBe('a: 1; b: x, y')
131
+ expect(cumulativeResultsHelper.renderResultSummary({ summary: 'word '.repeat(100) }, 40).length).toBeLessThanOrEqual(40)
132
+ })
133
+
134
+ test('merging declarations accumulates extractors and consumers, later scalars win', () => {
135
+ const merged = cumulativeResultsHelper.mergeResultsSpecs(
136
+ { window: 1, steps: { types: { extractors: ['ts'], full: ['resources'], maxChars: 100 } } },
137
+ null,
138
+ { window: 3, steps: { types: { extractors: ['ts', 'schema'], full: ['endpoints'], maxChars: 200 } } },
139
+ { steps: { files: { full: RESULTS_EVERY_STEP } } },
140
+ )
141
+
142
+ expect(merged.window).toBe(3)
143
+ expect(merged.steps?.types).toEqual({
144
+ extractors: ['ts', 'schema'], full: ['resources', 'endpoints'], maxChars: 200,
145
+ })
146
+ expect(merged.steps?.files?.full).toBe(RESULTS_EVERY_STEP)
147
+ })
148
+ })
package/src/flows.ts DELETED
@@ -1,118 +0,0 @@
1
- import type { ShallowFlow } from '@owlmeans/flow'
2
- import { AGENT_RUN_FLOW, AgentRunStep, AgentRunTransition } from './consts.js'
3
-
4
- /**
5
- * The lifecycle of one agent run.
6
- *
7
- * Read it as "how far did this run get", not "what did it say". Everything conversational happens
8
- * inside `Working`; the steps exist so that a run interrupted anywhere can be told where to pick
9
- * up. `Fail` is reachable from every working step, and `Resume` re-enters `Working` from a
10
- * checkpoint.
11
- *
12
- * `service` is left as the flow name on every step. A flow step normally binds to a service or an
13
- * entrypoint, but an agent run is driven by whoever holds the model — there is no second party to
14
- * hand control to, and inventing one would put a name in the serialized state that nothing
15
- * resolves.
16
- *
17
- * Each working step keeps exactly ONE non-explicit outgoing transition, so `FlowModel.next()`
18
- * always has an unambiguous answer: that is what lets a driver advance the run without knowing the
19
- * vocabulary. `Fail` is marked explicit precisely so it never becomes that automatic answer.
20
- */
21
- export const agentRunFlow: ShallowFlow = {
22
- flow: AGENT_RUN_FLOW,
23
- initialStep: AgentRunStep.Received,
24
-
25
- steps: {
26
- [AgentRunStep.Received]: {
27
- index: 0,
28
- step: AgentRunStep.Received,
29
- service: AGENT_RUN_FLOW,
30
- initial: true,
31
- transitions: {
32
- [AgentRunTransition.Prepare]: {
33
- transition: AgentRunTransition.Prepare,
34
- step: AgentRunStep.Prepared,
35
- },
36
- [AgentRunTransition.Fail]: {
37
- transition: AgentRunTransition.Fail,
38
- step: AgentRunStep.Failed,
39
- explicit: true,
40
- },
41
- },
42
- },
43
-
44
- [AgentRunStep.Prepared]: {
45
- index: 1,
46
- step: AgentRunStep.Prepared,
47
- service: AGENT_RUN_FLOW,
48
- transitions: {
49
- [AgentRunTransition.Work]: {
50
- transition: AgentRunTransition.Work,
51
- step: AgentRunStep.Working,
52
- },
53
- [AgentRunTransition.Fail]: {
54
- transition: AgentRunTransition.Fail,
55
- step: AgentRunStep.Failed,
56
- explicit: true,
57
- },
58
- },
59
- },
60
-
61
- [AgentRunStep.Working]: {
62
- index: 2,
63
- step: AgentRunStep.Working,
64
- service: AGENT_RUN_FLOW,
65
- transitions: {
66
- [AgentRunTransition.Finalize]: {
67
- transition: AgentRunTransition.Finalize,
68
- step: AgentRunStep.Finalizing,
69
- },
70
- [AgentRunTransition.Fail]: {
71
- transition: AgentRunTransition.Fail,
72
- step: AgentRunStep.Failed,
73
- explicit: true,
74
- },
75
- },
76
- },
77
-
78
- [AgentRunStep.Finalizing]: {
79
- index: 3,
80
- step: AgentRunStep.Finalizing,
81
- service: AGENT_RUN_FLOW,
82
- transitions: {
83
- [AgentRunTransition.Finish]: {
84
- transition: AgentRunTransition.Finish,
85
- step: AgentRunStep.Finished,
86
- },
87
- [AgentRunTransition.Fail]: {
88
- transition: AgentRunTransition.Fail,
89
- step: AgentRunStep.Failed,
90
- explicit: true,
91
- },
92
- },
93
- },
94
-
95
- [AgentRunStep.Finished]: {
96
- index: 4,
97
- step: AgentRunStep.Finished,
98
- service: AGENT_RUN_FLOW,
99
- transitions: {},
100
- },
101
-
102
- [AgentRunStep.Failed]: {
103
- index: 5,
104
- step: AgentRunStep.Failed,
105
- service: AGENT_RUN_FLOW,
106
- transitions: {
107
- [AgentRunTransition.Resume]: {
108
- transition: AgentRunTransition.Resume,
109
- step: AgentRunStep.Working,
110
- explicit: true,
111
- },
112
- },
113
- },
114
- },
115
- }
116
-
117
- /** Every flow this package declares, for a provider to serve. */
118
- export const agentFlows: ShallowFlow[] = [agentRunFlow]