@owlmeans/agent-common 0.1.18-rc.9 → 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.
- package/README.md +174 -13
- package/agent-meta/manifest.json +3 -3
- package/agent-meta/skills/agent-common/SKILL.md +69 -14
- package/build/consts.d.ts +118 -5
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +204 -4
- package/build/consts.js.map +1 -1
- package/build/consts.local.d.ts +9 -0
- package/build/consts.local.d.ts.map +1 -0
- package/build/consts.local.js +9 -0
- package/build/consts.local.js.map +1 -0
- package/build/errors.d.ts +60 -0
- package/build/errors.d.ts.map +1 -1
- package/build/errors.js +84 -0
- package/build/errors.js.map +1 -1
- package/build/helpers/conversation.d.ts +0 -9
- package/build/helpers/conversation.d.ts.map +1 -1
- package/build/helpers/conversation.js +0 -28
- package/build/helpers/conversation.js.map +1 -1
- package/build/helpers/truncate.d.ts +10 -0
- package/build/helpers/truncate.d.ts.map +1 -0
- package/build/helpers/truncate.js +29 -0
- package/build/helpers/truncate.js.map +1 -0
- package/build/index.d.ts +6 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +4 -1
- package/build/index.js.map +1 -1
- package/build/models/pipeline-spec/types.d.ts +57 -0
- package/build/models/pipeline-spec/types.d.ts.map +1 -0
- package/build/models/pipeline-spec/types.js +2 -0
- package/build/models/pipeline-spec/types.js.map +1 -0
- package/build/models/pipeline-spec.d.ts +4 -0
- package/build/models/pipeline-spec.d.ts.map +1 -0
- package/build/models/pipeline-spec.js +171 -0
- package/build/models/pipeline-spec.js.map +1 -0
- package/build/pipeline.d.ts +10 -0
- package/build/pipeline.d.ts.map +1 -0
- package/build/pipeline.js +10 -0
- package/build/pipeline.js.map +1 -0
- package/build/results/types.d.ts +211 -0
- package/build/results/types.d.ts.map +1 -0
- package/build/results/types.js +2 -0
- package/build/results/types.js.map +1 -0
- package/build/results.d.ts +4 -0
- package/build/results.d.ts.map +1 -0
- package/build/results.js +218 -0
- package/build/results.js.map +1 -0
- package/build/types.d.ts +146 -24
- package/build/types.d.ts.map +1 -1
- package/package.json +8 -8
- package/src/consts.local.ts +15 -0
- package/src/consts.ts +218 -4
- package/src/errors.ts +96 -0
- package/src/helpers/conversation.ts +0 -32
- package/src/helpers/truncate.ts +31 -0
- package/src/index.ts +6 -1
- package/src/models/pipeline-spec/types.ts +58 -0
- package/src/models/pipeline-spec.ts +202 -0
- package/src/pipeline.ts +16 -0
- package/src/results/types.ts +221 -0
- package/src/results.ts +270 -0
- package/src/types.ts +153 -25
- package/tests/pipeline.spec.ts +164 -0
- package/tests/results.spec.ts +148 -0
- 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
|
|
3
|
-
import type {
|
|
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
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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
|
-
|
|
85
|
+
runId: string
|
|
101
86
|
conversationId: string
|
|
102
|
-
|
|
103
|
-
|
|
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]
|