@defrex/autobuild 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/SPEC.md +313 -26
- package/bin/ab-build-runner.ts +8 -2
- package/docs/architecture.md +57 -14
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +151 -25
- package/docs/hosted-dispatcher.md +69 -13
- package/docs/operator-api.md +2 -2
- package/docs/remote-store-protocol.md +169 -16
- package/docs/remote-ticket-protocol.md +1 -1
- package/docs/setup.md +12 -9
- package/docs/website.md +10 -0
- package/package.json +43 -23
- package/packages/core/src/cli/artifact.ts +130 -6
- package/packages/core/src/cli/binary.ts +9 -1
- package/packages/core/src/cli/build-control.ts +110 -13
- package/packages/core/src/cli/context.ts +46 -0
- package/packages/core/src/cli/dashboard/model.ts +57 -7
- package/packages/core/src/cli/dashboard/poll.ts +69 -10
- package/packages/core/src/cli/dashboard/render.ts +34 -5
- package/packages/core/src/cli/dispatch-frontend.ts +54 -5
- package/packages/core/src/cli/dispatch.ts +320 -46
- package/packages/core/src/cli/harvest.ts +122 -8
- package/packages/core/src/cli/help.ts +47 -8
- package/packages/core/src/cli/init-readiness-remote.ts +110 -0
- package/packages/core/src/cli/init-readiness-shared.ts +72 -0
- package/packages/core/src/cli/init-validation.ts +248 -222
- package/packages/core/src/cli/main.ts +70 -11
- package/packages/core/src/cli/mcp.ts +23 -1
- package/packages/core/src/cli/plugin.ts +7 -4
- package/packages/core/src/cli/remote-poll.ts +277 -0
- package/packages/core/src/cli/repo-state.ts +22 -0
- package/packages/core/src/cli/repository-status.ts +39 -1
- package/packages/core/src/cli/status.ts +149 -27
- package/packages/core/src/cli/terminals.ts +2 -1
- package/packages/core/src/cli/ticket-assets.ts +247 -0
- package/packages/core/src/cli/ticket.ts +307 -7
- package/packages/core/src/cli/wait.ts +147 -39
- package/packages/core/src/cli/watch.ts +128 -166
- package/packages/core/src/config/live.ts +22 -0
- package/packages/core/src/config/pipeline-source.ts +376 -0
- package/packages/core/src/config/schema.ts +230 -19
- package/packages/core/src/events/catalog.ts +1 -0
- package/packages/core/src/events/globs.ts +47 -0
- package/packages/core/src/events/payloads.ts +52 -2
- package/packages/core/src/events/repository.ts +52 -1
- package/packages/core/src/events/sessions.ts +69 -10
- package/packages/core/src/events/workspace-remote.ts +12 -0
- package/packages/core/src/integration/harness.ts +2 -1
- package/packages/core/src/kernel/auto-merge-default.ts +123 -0
- package/packages/core/src/kernel/phases.ts +16 -2
- package/packages/core/src/kernel/reducer.ts +40 -1
- package/packages/core/src/operator/annotations.ts +6 -0
- package/packages/core/src/operator/control.ts +19 -5
- package/packages/core/src/operator/index.ts +71 -6
- package/packages/core/src/operator/query.ts +208 -19
- package/packages/core/src/operator/registry.ts +55 -10
- package/packages/core/src/operator/requests.ts +9 -6
- package/packages/core/src/operator/sandbox-host.ts +140 -0
- package/packages/core/src/operator/sandbox.ts +247 -14
- package/packages/core/src/operator/ticket-source-backend.ts +47 -0
- package/packages/core/src/orchestrator/approvals.ts +115 -0
- package/packages/core/src/orchestrator/conversation.ts +167 -0
- package/packages/core/src/orchestrator/model.ts +86 -0
- package/packages/core/src/orchestrator/prompt.ts +61 -0
- package/packages/core/src/orchestrator/tools.ts +117 -0
- package/packages/core/src/orchestrator/turn-runner.ts +457 -0
- package/packages/core/src/plugin-sdk/index.ts +51 -1
- package/packages/core/src/plugins/load.ts +135 -7
- package/packages/core/src/plugins/manifest.ts +183 -10
- package/packages/core/src/plugins/registry.ts +73 -4
- package/packages/core/src/ports/forge/create.ts +1 -1
- package/packages/core/src/ports/forge/fake.ts +31 -0
- package/packages/core/src/ports/forge/github-transport.ts +13 -2
- package/packages/core/src/ports/forge/github.ts +13 -2
- package/packages/core/src/ports/forge/local-git.ts +31 -1
- package/packages/core/src/ports/runner/claude.ts +2 -2
- package/packages/core/src/ports/runner/codex.ts +6 -2
- package/packages/core/src/ports/runner/fake.ts +7 -1
- package/packages/core/src/ports/runner/pi.ts +8 -6
- package/packages/core/src/ports/runner/provider-error.ts +13 -3
- package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
- package/packages/core/src/ports/types.ts +17 -2
- package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
- package/packages/core/src/ports/workspace/create.ts +74 -39
- package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
- package/packages/core/src/ports/workspace/fake.ts +169 -4
- package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
- package/packages/core/src/ports/workspace/github-origin.ts +36 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
- package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
- package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
- package/packages/core/src/processes/build-child.ts +17 -2
- package/packages/core/src/processes/build-execution-state.ts +37 -0
- package/packages/core/src/processes/build-runner.ts +54 -3
- package/packages/core/src/processes/dispatcher.ts +247 -41
- package/packages/core/src/processes/harvest-child.ts +5 -0
- package/packages/core/src/processes/harvest-runner.ts +66 -19
- package/packages/core/src/processes/harvest.ts +196 -24
- package/packages/core/src/processes/orchestrator-tick.ts +319 -0
- package/packages/core/src/processes/sandbox-state.ts +10 -0
- package/packages/core/src/store/adapter.ts +32 -0
- package/packages/core/src/store/build-scope.ts +64 -0
- package/packages/core/src/store/contract.ts +868 -0
- package/packages/core/src/store/digest.ts +49 -0
- package/packages/core/src/store/local/schema.ts +29 -2
- package/packages/core/src/store/local/store.ts +338 -4
- package/packages/core/src/store/memory.ts +179 -0
- package/packages/core/src/store/new-build.ts +17 -0
- package/packages/core/src/store/phase-session.ts +71 -0
- package/packages/core/src/store/remote/client.ts +260 -1
- package/packages/core/src/store/remote/index.ts +12 -2
- package/packages/core/src/store/remote/protocol.ts +100 -12
- package/packages/core/src/store/remote/version.ts +6 -2
- package/packages/core/src/store/repo-state-events.ts +140 -0
- package/packages/core/src/store/retention.ts +7 -0
- package/packages/core/src/store/session-handle.ts +64 -0
- package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +28 -9
- package/packages/core/src/store/streams/session-writer.ts +42 -2
- package/packages/core/src/store/streams/wait.ts +4 -5
- package/packages/core/src/store/ticket-assets.ts +425 -0
- package/packages/core/src/store/types.ts +122 -1
- package/packages/core/src/testing/index.ts +59 -0
- package/skills/code-review/SKILL.md +5 -1
- package/skills/guide/SKILL.md +126 -33
- package/skills/guide/references/operator-api.md +2 -2
- package/skills/guide/references/plugin-authoring.md +17 -0
- package/skills/guide/references/remote-store-protocol.md +169 -16
- package/skills/guide/references/remote-ticket-protocol.md +1 -1
- package/skills/guide/references/setup.md +12 -9
- package/skills/implement/SKILL.md +3 -0
- package/skills/operate/SKILL.md +126 -0
- package/skills/plan/SKILL.md +4 -0
- package/skills/plan-review/SKILL.md +5 -1
- package/skills/spec/SKILL.md +46 -3
- package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
- package/packages/core/src/cli/store-opening.contract.ts +0 -289
- package/packages/core/src/cli/testkit.ts +0 -194
- package/packages/core/src/markdown.ts +0 -67
- package/packages/core/src/operator/client.ts +0 -311
- package/packages/core/src/operator/server.ts +0 -616
- package/packages/core/src/operator/sessions.ts +0 -223
- package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
- package/packages/core/src/ports/tickets/remote-server.ts +0 -185
- package/packages/core/src/store/remote/server.ts +0 -923
- package/packages/core/src/testing/store-failures.ts +0 -32
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A dispatcher ticket source as the registry's ticket backend (AUT-342):
|
|
3
|
+
* the tick's turn runner takes an `OperatorToolRegistry`, and
|
|
4
|
+
* `buildRegistry`'s ticket tools require an `OperatorTicketBackend` — but
|
|
5
|
+
* the dispatcher's wiring holds a `TicketSource`, not a backend, and
|
|
6
|
+
* `buildMcpTicketBackend` reads a checkout's autobuild.toml, which cannot
|
|
7
|
+
* run in origin mode. This adapter hands the dispatcher's
|
|
8
|
+
* already-constructed source straight through (it was built by
|
|
9
|
+
* `createTicketSource` from the same effective `[tickets]` config the
|
|
10
|
+
* `OperatorTicketContext` would derive, for the same repository) and derives
|
|
11
|
+
* the workflow-state list from the effective config's lifecycle names.
|
|
12
|
+
*/
|
|
13
|
+
import type { Config } from '../config/schema'
|
|
14
|
+
import type { OperatorTicketBackend, OperatorTicketContext } from './tickets'
|
|
15
|
+
import type { TicketSource } from '../ports/types'
|
|
16
|
+
|
|
17
|
+
/** The dispatcher's resolved triage state (the same default
|
|
18
|
+
* `processes/dispatcher.ts` applies); inlined rather than imported so this
|
|
19
|
+
* operator-seam module does not pull the dispatcher's module graph. */
|
|
20
|
+
function defaultTriageState(config: Config): string {
|
|
21
|
+
return (
|
|
22
|
+
config.tickets.triageState ??
|
|
23
|
+
(config.tickets.source === 'linear' || config.tickets.source === 'hosted'
|
|
24
|
+
? 'Backlog'
|
|
25
|
+
: 'Triage')
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function ticketBackendFromSource(options: {
|
|
30
|
+
source: TicketSource
|
|
31
|
+
config: Config
|
|
32
|
+
}): OperatorTicketBackend {
|
|
33
|
+
return {
|
|
34
|
+
sourceFor: (_context: OperatorTicketContext) => options.source,
|
|
35
|
+
statesFor: (_context: OperatorTicketContext, _source: TicketSource): string[] => {
|
|
36
|
+
const tickets = options.config.tickets
|
|
37
|
+
const states = [
|
|
38
|
+
tickets.triageState ?? defaultTriageState(options.config),
|
|
39
|
+
tickets.readyState,
|
|
40
|
+
tickets.createState,
|
|
41
|
+
tickets.claimedState,
|
|
42
|
+
tickets.proposalState ?? defaultTriageState(options.config),
|
|
43
|
+
].filter((state): state is string => typeof state === 'string' && state.trim().length > 0)
|
|
44
|
+
return [...new Set(states)]
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The turn approval list (AUT-342): which registry tool calls suspend a turn
|
|
3
|
+
* until the operator answers. Configured as `[orchestrator].approvals` — a
|
|
4
|
+
* list of registry tool names, optionally qualified by the accepted value of
|
|
5
|
+
* the tool's discriminator field (`tool:qualifier`).
|
|
6
|
+
*
|
|
7
|
+
* Semantics:
|
|
8
|
+
* - A bare entry (`builds.control`) suspends every call to that tool.
|
|
9
|
+
* - A qualified entry (`tickets.move:ready`) suspends only calls whose
|
|
10
|
+
* discriminator equals the qualifier; other values of the same tool run
|
|
11
|
+
* without pause.
|
|
12
|
+
* - An entry naming an absent or unknown tool is inert by design, never an
|
|
13
|
+
* error — the default list is stable while the registry grows.
|
|
14
|
+
* - A qualified entry for a tool without a declared discriminator never
|
|
15
|
+
* matches (there is nothing to compare).
|
|
16
|
+
*
|
|
17
|
+
* Nothing here encodes this repository's specifics.
|
|
18
|
+
*/
|
|
19
|
+
import type { ToolApprovalStatus } from 'ai'
|
|
20
|
+
|
|
21
|
+
/** A per-tool approval configuration value: a static status or a function
|
|
22
|
+
* deciding from the call's input. (The SDK's per-tool map accepts both.) */
|
|
23
|
+
export type ApprovalDecision = ToolApprovalStatus | ((input: unknown) => ToolApprovalStatus)
|
|
24
|
+
|
|
25
|
+
/** The registry tools' discriminator fields, for qualified entries. A tool
|
|
26
|
+
* absent from this map has no discriminator; only bare entries match it. */
|
|
27
|
+
const DISCRIMINATOR_FIELDS: Record<string, string> = {
|
|
28
|
+
'builds.control': 'action',
|
|
29
|
+
'builds.answer': 'resolution',
|
|
30
|
+
'tickets.move': 'state',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ParsedApprovalEntry {
|
|
34
|
+
tool: string
|
|
35
|
+
qualifier?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Parse one configured entry into its tool and optional qualifier. The
|
|
39
|
+
* config schema already enforces the entry shape syntactically. */
|
|
40
|
+
export function parseApprovalEntry(entry: string): ParsedApprovalEntry {
|
|
41
|
+
const separator = entry.indexOf(':')
|
|
42
|
+
if (separator === -1) return { tool: entry }
|
|
43
|
+
return { tool: entry.slice(0, separator), qualifier: entry.slice(separator + 1) }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Parse the whole configured list. Duplicate entries collapse harmlessly:
|
|
47
|
+
* the resulting matcher is a predicate, not a table. */
|
|
48
|
+
export function parseApprovalEntries(entries: readonly string[]): ParsedApprovalEntry[] {
|
|
49
|
+
return entries.map(parseApprovalEntry)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The AI SDK `toolApproval` per-tool configuration for the parsed list: a
|
|
54
|
+
* bare entry maps the whole tool to `user-approval`; a qualified entry maps
|
|
55
|
+
* to a function that returns `user-approval` when the discriminator equals
|
|
56
|
+
* one of the tool's configured qualifiers, else `not-applicable`. Tools with
|
|
57
|
+
* no matching entry get no key at all — the SDK treats an absent key as
|
|
58
|
+
* not-applicable.
|
|
59
|
+
*/
|
|
60
|
+
export function approvalConfiguration(
|
|
61
|
+
entries: readonly ParsedApprovalEntry[],
|
|
62
|
+
): Record<string, ApprovalDecision> {
|
|
63
|
+
const bare = new Set<string>()
|
|
64
|
+
const qualified = new Map<string, Set<string>>()
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
if (entry.qualifier === undefined) {
|
|
67
|
+
bare.add(entry.tool)
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
if (bare.has(entry.tool)) continue
|
|
71
|
+
const set = qualified.get(entry.tool) ?? new Set<string>()
|
|
72
|
+
set.add(entry.qualifier)
|
|
73
|
+
qualified.set(entry.tool, set)
|
|
74
|
+
}
|
|
75
|
+
const configuration: Record<string, ApprovalDecision> = {}
|
|
76
|
+
for (const tool of bare) configuration[tool] = 'user-approval'
|
|
77
|
+
for (const [tool, qualifiers] of qualified) {
|
|
78
|
+
if (bare.has(tool)) continue
|
|
79
|
+
const field = DISCRIMINATOR_FIELDS[tool]
|
|
80
|
+
configuration[tool] = (input: unknown) => {
|
|
81
|
+
// A qualified entry for a tool without a declared discriminator never
|
|
82
|
+
// matches — there is nothing to compare.
|
|
83
|
+
if (field === undefined) return 'not-applicable'
|
|
84
|
+
const discriminator =
|
|
85
|
+
typeof input === 'object' && input !== null
|
|
86
|
+
? (input as Record<string, unknown>)[field]
|
|
87
|
+
: undefined
|
|
88
|
+
return typeof discriminator === 'string' && qualifiers.has(discriminator)
|
|
89
|
+
? 'user-approval'
|
|
90
|
+
: 'not-applicable'
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return configuration
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Whether a specific call would be suspended — the mirror of
|
|
97
|
+
* `approvalConfiguration` for tests and for the runner's own diagnostics. */
|
|
98
|
+
export function callNeedsApproval(
|
|
99
|
+
entries: readonly ParsedApprovalEntry[],
|
|
100
|
+
tool: string,
|
|
101
|
+
input: unknown,
|
|
102
|
+
): boolean {
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
if (entry.tool !== tool) continue
|
|
105
|
+
if (entry.qualifier === undefined) return true
|
|
106
|
+
const field = DISCRIMINATOR_FIELDS[tool]
|
|
107
|
+
if (field === undefined) continue
|
|
108
|
+
const discriminator =
|
|
109
|
+
typeof input === 'object' && input !== null
|
|
110
|
+
? (input as Record<string, unknown>)[field]
|
|
111
|
+
: undefined
|
|
112
|
+
if (discriminator === entry.qualifier) return true
|
|
113
|
+
}
|
|
114
|
+
return false
|
|
115
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable conversation reconstruction (AUT-342): a turn runner holds no
|
|
3
|
+
* process memory. On every invocation the whole conversation is rebuilt from
|
|
4
|
+
* the session's durable state alone — the finalized artifacts of earlier
|
|
5
|
+
* turns, the live stream of the open turn, and the session events.
|
|
6
|
+
*
|
|
7
|
+
* Ordering rule: a turn's assembled messages attach AT ITS `turn.started`
|
|
8
|
+
* event, not at its terminal fact. Attaching at the terminal fact would
|
|
9
|
+
* place a message posted while the turn was suspended BEFORE that turn's
|
|
10
|
+
* already-generated output, interleaving the new message into the middle of
|
|
11
|
+
* the checkpoint the original invocation produced — breaking the identical-
|
|
12
|
+
* input guarantee for a resumed turn in exactly the reachable case (a
|
|
13
|
+
* message landing during a suspension). Attached at `turn.started`, every
|
|
14
|
+
* step the original invocation completed appears verbatim and in the
|
|
15
|
+
* position the model generated it from, and a mid-suspension message reads
|
|
16
|
+
* as a plain next user message.
|
|
17
|
+
*
|
|
18
|
+
* Message-triggered turns contribute no input message of their own (the
|
|
19
|
+
* `message.posted` fact is the input); wake-triggered turns carry their
|
|
20
|
+
* input — the event record and the build's reduced state, frozen at wake
|
|
21
|
+
* time — in the `turn.started` payload, so reconstruction is byte-identical
|
|
22
|
+
* across invocations even though live build state keeps moving.
|
|
23
|
+
*/
|
|
24
|
+
import { assembleUIMessageDocument } from '../store/streams/assemble'
|
|
25
|
+
import type { StreamPart, StreamRead } from '../store/streams/types'
|
|
26
|
+
import type { UIMessage } from 'ai'
|
|
27
|
+
import type { BuildStore } from '../store/types'
|
|
28
|
+
import type { SessionEvent } from '../events/sessions'
|
|
29
|
+
import type { StreamRecord } from '../store/streams/types'
|
|
30
|
+
|
|
31
|
+
const STREAM_ARTIFACT_PREFIX = 'stream:'
|
|
32
|
+
|
|
33
|
+
interface TurnStreamSource {
|
|
34
|
+
/** The turn's stream id, from its `turn.started` fact. */
|
|
35
|
+
stream: string
|
|
36
|
+
/** Durable status of the stream at read time. */
|
|
37
|
+
status: 'open' | 'closed'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function readTurnDocument(
|
|
41
|
+
store: BuildStore,
|
|
42
|
+
sessionId: string,
|
|
43
|
+
source: TurnStreamSource,
|
|
44
|
+
): Promise<UIMessage[]> {
|
|
45
|
+
if (source.status === 'open') {
|
|
46
|
+
const read: StreamRead = await store.readStream(source.stream)
|
|
47
|
+
const parts: StreamPart[] = read.chunks.flatMap((chunk) => chunk.parts)
|
|
48
|
+
const { document } = await assembleUIMessageDocument(parts)
|
|
49
|
+
return document
|
|
50
|
+
}
|
|
51
|
+
// Closed streams finalize into the `stream:<id>` session artifact; the
|
|
52
|
+
// chunks themselves are retention-pruned, so the artifact is the durable
|
|
53
|
+
// form a later invocation must read.
|
|
54
|
+
const artifact = await store.getSessionArtifact(
|
|
55
|
+
sessionId,
|
|
56
|
+
`${STREAM_ARTIFACT_PREFIX}${source.stream}`,
|
|
57
|
+
)
|
|
58
|
+
if (artifact === null) return []
|
|
59
|
+
let parsed: unknown
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(new TextDecoder().decode(artifact.content))
|
|
62
|
+
} catch {
|
|
63
|
+
return []
|
|
64
|
+
}
|
|
65
|
+
return Array.isArray(parsed) ? (parsed as UIMessage[]) : []
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ReconstructedConversation {
|
|
69
|
+
messages: UIMessage[]
|
|
70
|
+
/** Per turn, in start order: the turn id and how many messages its
|
|
71
|
+
* document contributed. Diagnostics, not contract. */
|
|
72
|
+
turns: { turn: string; messages: number }[]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Reconstruct the conversation for a session from durable state alone.
|
|
77
|
+
* `events` must be the complete session event log (seq 1 upward).
|
|
78
|
+
*/
|
|
79
|
+
export async function reconstructConversation(
|
|
80
|
+
store: BuildStore,
|
|
81
|
+
sessionId: string,
|
|
82
|
+
events: SessionEvent[],
|
|
83
|
+
): Promise<ReconstructedConversation> {
|
|
84
|
+
const messages: UIMessage[] = []
|
|
85
|
+
const turns: ReconstructedConversation['turns'] = []
|
|
86
|
+
|
|
87
|
+
// Resolve each turn's stream status up front so the walk stays ordered:
|
|
88
|
+
// the open turn reads its live stream, closed turns read their artifacts.
|
|
89
|
+
const sources = new Map<string, TurnStreamSource>()
|
|
90
|
+
for (const event of events) {
|
|
91
|
+
if (event.type !== 'turn.started') continue
|
|
92
|
+
const record: StreamRecord | null = await store.getStream(event.payload.stream)
|
|
93
|
+
sources.set(event.payload.turn, {
|
|
94
|
+
stream: event.payload.stream,
|
|
95
|
+
status: record?.status === 'open' ? 'open' : 'closed',
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (const event of events) {
|
|
100
|
+
if (event.type === 'message.posted') {
|
|
101
|
+
messages.push({
|
|
102
|
+
id: `msg_${event.seq}`,
|
|
103
|
+
role: 'user',
|
|
104
|
+
parts: [{ type: 'text', text: event.payload.text }],
|
|
105
|
+
})
|
|
106
|
+
} else if (event.type === 'turn.started') {
|
|
107
|
+
const source = sources.get(event.payload.turn)
|
|
108
|
+
if (source === undefined) continue
|
|
109
|
+
// A wake turn's input is the frozen snapshot carried by its own
|
|
110
|
+
// `turn.started` fact — recorded before the fact, so every invocation
|
|
111
|
+
// reconstructs the identical user message.
|
|
112
|
+
if (event.payload.trigger.kind === 'wake' && event.payload.wake !== undefined) {
|
|
113
|
+
messages.push(
|
|
114
|
+
wakeTriggerInputMessage({
|
|
115
|
+
build: event.payload.trigger.build,
|
|
116
|
+
journal: event.payload.trigger.journal === true,
|
|
117
|
+
type: event.payload.trigger.type,
|
|
118
|
+
event: event.payload.wake.event,
|
|
119
|
+
buildState: event.payload.wake.buildState,
|
|
120
|
+
}),
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
const document = await readTurnDocument(store, sessionId, source)
|
|
124
|
+
turns.push({ turn: event.payload.turn, messages: document.length })
|
|
125
|
+
messages.push(...document)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { messages, turns }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The user input message a wake trigger delivers to its turn: the attention
|
|
134
|
+
* event record and — for a build wake — the build's reduced state, as JSON.
|
|
135
|
+
* A journal wake names the repository journal instead of a build and carries
|
|
136
|
+
* no build-state section (there is no build; the turn's operator registry
|
|
137
|
+
* exposes bounded repository reads). The runner embeds the same object in
|
|
138
|
+
* the `turn.started` payload, so this function and the reconstruction agree
|
|
139
|
+
* by construction.
|
|
140
|
+
*/
|
|
141
|
+
export function wakeTriggerInputMessage(input: {
|
|
142
|
+
build?: string
|
|
143
|
+
journal?: boolean
|
|
144
|
+
type: string
|
|
145
|
+
event: { seq: number; ts: string; type: string; payload: unknown }
|
|
146
|
+
buildState?: unknown
|
|
147
|
+
}): UIMessage {
|
|
148
|
+
const journal = input.journal === true || input.build === undefined
|
|
149
|
+
return {
|
|
150
|
+
id: journal ? `wake_journal_${input.event.seq}` : `wake_${input.build}_${input.event.seq}`,
|
|
151
|
+
role: 'user',
|
|
152
|
+
parts: [
|
|
153
|
+
{
|
|
154
|
+
type: 'text',
|
|
155
|
+
text: journal
|
|
156
|
+
? `Repository-journal attention event: ${input.type} (seq ${input.event.seq}).\n` +
|
|
157
|
+
'Event record:\n' +
|
|
158
|
+
JSON.stringify(input.event, null, 2)
|
|
159
|
+
: `Attention event on build ${input.build}: ${input.type} (seq ${input.event.seq}).\n` +
|
|
160
|
+
'Event record:\n' +
|
|
161
|
+
JSON.stringify(input.event, null, 2) +
|
|
162
|
+
'\nBuild state:\n' +
|
|
163
|
+
JSON.stringify(input.buildState, null, 2),
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model resolution and typed failure classification (AUT-342): the
|
|
3
|
+
* orchestrator's model comes from `[orchestrator].model`, a provider-
|
|
4
|
+
* qualified string in the same vocabulary as `[roles]`, resolved through the
|
|
5
|
+
* deployment's gateway credential. Model failures are typed on
|
|
6
|
+
* `turn.failed` — provider availability, exhaustion, credentials, and
|
|
7
|
+
* configuration are distinguished so nothing is ever retried unboundedly and
|
|
8
|
+
* the class is visible in the session's reduced state.
|
|
9
|
+
*/
|
|
10
|
+
import { LoadAPIKeyError, createGateway } from 'ai'
|
|
11
|
+
import {
|
|
12
|
+
GatewayAuthenticationError,
|
|
13
|
+
GatewayInternalServerError,
|
|
14
|
+
GatewayModelNotFoundError,
|
|
15
|
+
GatewayRateLimitError,
|
|
16
|
+
} from '@ai-sdk/gateway'
|
|
17
|
+
|
|
18
|
+
/** Strip the optional `vercel-ai-gateway/` prefix so the value is written
|
|
19
|
+
* exactly as `[roles]` spells it while the gateway provider receives the
|
|
20
|
+
* bare model id. */
|
|
21
|
+
export function resolveGatewayModelId(model: string): string {
|
|
22
|
+
const prefix = 'vercel-ai-gateway/'
|
|
23
|
+
return model.startsWith(prefix) ? model.slice(prefix.length) : model
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Resolve the configured model string through the deployment's gateway
|
|
27
|
+
* credential (`AI_GATEWAY_API_KEY`, read by `createGateway`). Missing or
|
|
28
|
+
* rejected credentials surface at call time and classify as `credentials`. */
|
|
29
|
+
export function resolveGatewayModel(model: string): ReturnType<ReturnType<typeof createGateway>> {
|
|
30
|
+
return createGateway()(resolveGatewayModelId(model))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The typed model-failure vocabulary, mirroring `turn.failed`'s `kind`. */
|
|
34
|
+
export type TurnFailureKind =
|
|
35
|
+
| 'provider-unavailable'
|
|
36
|
+
| 'exhausted'
|
|
37
|
+
| 'credentials'
|
|
38
|
+
| 'configuration'
|
|
39
|
+
| 'internal'
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Classify one model error. The gateway's own error classes are the primary
|
|
43
|
+
* vocabulary: `GatewayModelNotFoundError` (`type: 'model_not_found'`, thrown
|
|
44
|
+
* at call time — the gateway constructs a model for any id, so unknown-model
|
|
45
|
+
* is never `NoSuchModelError`) is `configuration`;
|
|
46
|
+
* `GatewayAuthenticationError` or `LoadAPIKeyError` (which the gateway does
|
|
47
|
+
* not export; `ai` re-exports it from `@ai-sdk/provider`) is `credentials`;
|
|
48
|
+
* `GatewayRateLimitError` or any 429 is `exhausted`;
|
|
49
|
+
* `GatewayInternalServerError` and other 5xx/network/timeout errors are
|
|
50
|
+
* `provider-unavailable`. Remaining errors classify by their `statusCode`
|
|
51
|
+
* when present, else `internal`.
|
|
52
|
+
*/
|
|
53
|
+
export function classifyModelError(error: unknown): TurnFailureKind {
|
|
54
|
+
// `ai` rewrites gateway authentication errors flowing through a stream
|
|
55
|
+
// into a plain Error (or AISDKError in production) that keeps only the
|
|
56
|
+
// class NAME — classify those by name before the instanceof checks.
|
|
57
|
+
const name =
|
|
58
|
+
typeof (error as { name?: unknown })?.name === 'string'
|
|
59
|
+
? ((error as { name?: string }).name ?? '')
|
|
60
|
+
: ''
|
|
61
|
+
if (name === 'GatewayAuthenticationError' || name === 'GatewayError') return 'credentials'
|
|
62
|
+
if (error instanceof GatewayModelNotFoundError) return 'configuration'
|
|
63
|
+
if (error instanceof GatewayAuthenticationError || error instanceof LoadAPIKeyError) {
|
|
64
|
+
return 'credentials'
|
|
65
|
+
}
|
|
66
|
+
if (error instanceof GatewayRateLimitError) return 'exhausted'
|
|
67
|
+
if (error instanceof GatewayInternalServerError) return 'provider-unavailable'
|
|
68
|
+
|
|
69
|
+
// Non-gateway shapes: classify by what they carry.
|
|
70
|
+
const candidate = error as { statusCode?: unknown; name?: unknown; message?: unknown }
|
|
71
|
+
if (typeof candidate?.statusCode === 'number') {
|
|
72
|
+
if (candidate.statusCode === 401 || candidate.statusCode === 403) return 'credentials'
|
|
73
|
+
if (candidate.statusCode === 404) return 'configuration'
|
|
74
|
+
if (candidate.statusCode === 429) return 'exhausted'
|
|
75
|
+
if (candidate.statusCode >= 500) return 'provider-unavailable'
|
|
76
|
+
}
|
|
77
|
+
const text = `${candidate?.name ?? ''} ${candidate?.message ?? ''}`.toLowerCase()
|
|
78
|
+
if (text.includes('api key') || text.includes('apikey') || text.includes('unauthorized')) {
|
|
79
|
+
return 'credentials'
|
|
80
|
+
}
|
|
81
|
+
if (text.includes('rate limit') || text.includes('too many requests')) return 'exhausted'
|
|
82
|
+
if (text.includes('timeout') || text.includes('econnrefused') || text.includes('enotfound')) {
|
|
83
|
+
return 'provider-unavailable'
|
|
84
|
+
}
|
|
85
|
+
return 'internal'
|
|
86
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The turn's system prompt (AUT-342): the canonical `ab-operate` skill body,
|
|
3
|
+
* plus the current `operator-notes` artifact, plus the session's wake
|
|
4
|
+
* settings, plus the session's repository identity.
|
|
5
|
+
*
|
|
6
|
+
* The skill is read from the distribution's `skills/operate/SKILL.md` — the
|
|
7
|
+
* same tree `ab init` vendors from — through `distributionPath`, and is
|
|
8
|
+
* shipped into every hosted function that executes a turn (see
|
|
9
|
+
* packages/hosted-dispatcher/src/ship-packed-distribution.ts). It is
|
|
10
|
+
* self-contained per the vendored-skill rule and teaches the tool surface,
|
|
11
|
+
* the attention set, and when to escalate rather than act; nothing
|
|
12
|
+
* repository-specific may be inlined here.
|
|
13
|
+
*/
|
|
14
|
+
import { readFile } from 'node:fs/promises'
|
|
15
|
+
import { distributionPath } from '../distribution'
|
|
16
|
+
import { OPERATOR_NOTES_REPO_KIND } from '../store/retention'
|
|
17
|
+
import type { BuildStore } from '../store/types'
|
|
18
|
+
import type { OrchestratorConfig } from '../config/schema'
|
|
19
|
+
|
|
20
|
+
/** Where the canonical skill lives in the distribution tree. */
|
|
21
|
+
export const OPERATE_SKILL_PATH = 'skills/operate/SKILL.md'
|
|
22
|
+
|
|
23
|
+
/** Read the canonical skill body from the distribution tree. Throws when
|
|
24
|
+
* the file is missing — a deployment that cannot load the canonical skill
|
|
25
|
+
* must not run turns with a silently empty system prompt. */
|
|
26
|
+
export async function readOperateSkill(): Promise<string> {
|
|
27
|
+
return readFile(distributionPath(...OPERATE_SKILL_PATH.split('/')), 'utf8')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TurnPromptInput {
|
|
31
|
+
store: BuildStore
|
|
32
|
+
repo: string
|
|
33
|
+
/** The session's wake globs (already resolved — empty means message-only). */
|
|
34
|
+
wakeGlobs: readonly string[]
|
|
35
|
+
config: OrchestratorConfig
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Assemble the system prompt. The operator-notes artifact is empty when
|
|
39
|
+
* absent; the skill body is always present (missing file throws). */
|
|
40
|
+
export async function buildTurnSystemPrompt(input: TurnPromptInput): Promise<string> {
|
|
41
|
+
const skill = await readOperateSkill()
|
|
42
|
+
const notes = await input.store.getRepoArtifact(input.repo, OPERATOR_NOTES_REPO_KIND)
|
|
43
|
+
const notesText = notes === null ? '' : new TextDecoder().decode(notes.content).trim()
|
|
44
|
+
return [
|
|
45
|
+
skill,
|
|
46
|
+
'',
|
|
47
|
+
'## Repository',
|
|
48
|
+
'',
|
|
49
|
+
`You operate the repository ${JSON.stringify(input.repo)}. Every tool call is bound to this repository; you never supply a repo argument yourself.`,
|
|
50
|
+
'',
|
|
51
|
+
'## Operator notes',
|
|
52
|
+
'',
|
|
53
|
+
notesText === '' ? '(none recorded)' : notesText,
|
|
54
|
+
'',
|
|
55
|
+
'## Wake settings',
|
|
56
|
+
'',
|
|
57
|
+
input.wakeGlobs.length === 0
|
|
58
|
+
? 'This session wakes only on operator messages (no attention-event wake sources).'
|
|
59
|
+
: `This session wakes on these attention-event globs: ${input.wakeGlobs.map((glob) => JSON.stringify(glob)).join(', ')}.`,
|
|
60
|
+
].join('\n')
|
|
61
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry entries as AI SDK tools (AUT-342): the turn runner binds the
|
|
3
|
+
* in-process operator registry, so the agent's tools ARE the operator
|
|
4
|
+
* surface — never a reimplementation.
|
|
5
|
+
*
|
|
6
|
+
* Two rules shape the binding:
|
|
7
|
+
*
|
|
8
|
+
* 1. **Attribution.** Every call carries the session operator as the human
|
|
9
|
+
* actor with `via: {kind: 'session', id}` — the operator's session did
|
|
10
|
+
* it, exactly as an MCP client's calls carry `via: {kind: 'mcp', …}`.
|
|
11
|
+
*
|
|
12
|
+
* 2. **The model never supplies `repo`.** Every registry entry requires a
|
|
13
|
+
* `repo` field and `call()` validates it before the handler runs, but
|
|
14
|
+
* nothing in the turn's durable state teaches the model a repository
|
|
15
|
+
* identity it could type: message-triggered turns carry no repo fact, the
|
|
16
|
+
* canonical skill must stay generic, and the registry has no
|
|
17
|
+
* repositories.list tool. The model-facing schema therefore strips `repo`
|
|
18
|
+
* (the mirror of the registry's own `extendUnion`), and `execute` injects
|
|
19
|
+
* the session's repository into the validated input before
|
|
20
|
+
* `registry.call` re-validates it — the session's repository is the only
|
|
21
|
+
* value a tool can ever target. The registry's `allowedRepo` guard stays
|
|
22
|
+
* bound as defense in depth.
|
|
23
|
+
*
|
|
24
|
+
* A thrown `RegistryError` is caught and returned as the operator-API
|
|
25
|
+
* failure body (`kind`/`error`/`code`) so the model can react to a refusal
|
|
26
|
+
* instead of the step dying; the registry's JSON-value invariant makes every
|
|
27
|
+
* output wire-safe.
|
|
28
|
+
*/
|
|
29
|
+
import { tool, type ToolSet } from 'ai'
|
|
30
|
+
import { z } from 'zod'
|
|
31
|
+
import { RegistryError, type OperatorToolRegistry } from '../operator/registry'
|
|
32
|
+
import type { Via } from '../events/envelope'
|
|
33
|
+
import type { ApprovalDecision } from './approvals'
|
|
34
|
+
|
|
35
|
+
/** The model-facing stand-in for a stripped `repo` on schemas Zod 4 refuses
|
|
36
|
+
* to `.omit()` — an object carrying refinements (`.refine` locks the shape).
|
|
37
|
+
* `safeExtend` preserves the refinements while replacing the field with an
|
|
38
|
+
* optional `never`: absent is fine, any supplied value fails validation, so
|
|
39
|
+
* the model still cannot point a tool anywhere but the session's
|
|
40
|
+
* repository. (Checked against the installed zod; `.omit` and `.extend`
|
|
41
|
+
* both throw on refined objects.) */
|
|
42
|
+
const unsatisfiableRepo = z.optional(z.never())
|
|
43
|
+
|
|
44
|
+
/** Strip `repo` from a tool's model-facing schema: plain `ZodObject` entries
|
|
45
|
+
* omit the field; union entries (the `extendUnion` shape) strip each option
|
|
46
|
+
* and rebuild the union — Zod 4 unions expose no `withOptions`, so the
|
|
47
|
+
* original union must never be handed through unchanged (its members still
|
|
48
|
+
* require `repo`, which the model cannot know). Refined objects neutralize
|
|
49
|
+
* the field instead of omitting it. Returns undefined only for a schema the
|
|
50
|
+
* entry table never uses. */
|
|
51
|
+
function stripRepoField(schema: z.ZodType): z.ZodType | undefined {
|
|
52
|
+
const options = (schema as unknown as { options?: readonly z.ZodType[] }).options
|
|
53
|
+
if (Array.isArray(options) && options.length > 0) {
|
|
54
|
+
const stripped = options.map((option) => stripRepoField(option) ?? option)
|
|
55
|
+
return z.union(stripped)
|
|
56
|
+
}
|
|
57
|
+
const object = schema as unknown as {
|
|
58
|
+
omit?: (mask: Record<string, boolean>) => unknown
|
|
59
|
+
safeExtend?: (patch: Record<string, z.ZodType>) => unknown
|
|
60
|
+
}
|
|
61
|
+
if (typeof object.omit === 'function') {
|
|
62
|
+
try {
|
|
63
|
+
return object.omit({ repo: true }) as z.ZodType
|
|
64
|
+
} catch {
|
|
65
|
+
// A refined object: neutralize `repo` instead of omitting it.
|
|
66
|
+
if (typeof object.safeExtend === 'function') {
|
|
67
|
+
return object.safeExtend({ repo: unsatisfiableRepo }) as z.ZodType
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface RegistryToolsInput {
|
|
75
|
+
registry: OperatorToolRegistry
|
|
76
|
+
/** The session's repository — the only value any tool can target. */
|
|
77
|
+
repo: string
|
|
78
|
+
/** The session operator: the human actor every attributed write names. */
|
|
79
|
+
operator: string
|
|
80
|
+
/** The session-scoped delegate marker. */
|
|
81
|
+
via: Via
|
|
82
|
+
/** Optional per-tool approval configuration (from `approvals.ts`), keyed
|
|
83
|
+
* by registry tool name; absent keys never suspend. (Kept for signature
|
|
84
|
+
* symmetry; the agent takes the configuration directly.) */
|
|
85
|
+
toolApproval?: Record<string, ApprovalDecision>
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Build the AI SDK tool set from the registry's closed table. */
|
|
89
|
+
export function registryTools(input: RegistryToolsInput): ToolSet {
|
|
90
|
+
const tools: ToolSet = {}
|
|
91
|
+
for (const entry of input.registry.entries) {
|
|
92
|
+
const modelSchema = stripRepoField(entry.inputSchema)
|
|
93
|
+
if (modelSchema === undefined) continue
|
|
94
|
+
tools[entry.name] = tool({
|
|
95
|
+
description: `${entry.description}${entry.outputDescription ? `\n\nReturns: ${entry.outputDescription}` : ''}`,
|
|
96
|
+
inputSchema: modelSchema,
|
|
97
|
+
execute: async (modelInput: unknown) => {
|
|
98
|
+
// Re-inject the session's repository: the registry re-validates the
|
|
99
|
+
// whole input (with `repo`) before the handler runs.
|
|
100
|
+
const fullInput =
|
|
101
|
+
typeof modelInput === 'object' && modelInput !== null
|
|
102
|
+
? { ...(modelInput as Record<string, unknown>), repo: input.repo }
|
|
103
|
+
: { repo: input.repo }
|
|
104
|
+
try {
|
|
105
|
+
return await input.registry.call(entry.name, fullInput, {
|
|
106
|
+
identity: input.operator,
|
|
107
|
+
via: input.via,
|
|
108
|
+
})
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error instanceof RegistryError) return error.body
|
|
111
|
+
throw error
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
return tools
|
|
117
|
+
}
|