@astrale-os/cli 0.6.3-alpha.0 → 0.8.0-alpha.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 +3 -2
- package/dist/astrale.js +339 -242
- package/package.json +2 -3
- package/src/commands/__tests__/admin-instance.test.ts +49 -2
- package/src/commands/__tests__/domain-install-owned.test.ts +122 -0
- package/src/commands/__tests__/help-contract.test.ts +15 -0
- package/src/commands/domain/install.ts +15 -4
- package/src/commands/instance/active.ts +4 -7
- package/src/commands/instance/list.ts +4 -8
- package/src/commands/instance/status.ts +14 -9
- package/src/commands/instance/use.ts +13 -11
- package/src/commands/studio.ts +13 -0
- package/src/commands/view.ts +40 -13
- package/src/kernel/__tests__/client-owned-lookup.test.ts +51 -0
- package/src/kernel/client.ts +47 -20
- package/src/kernel/expand.ts +3 -22
- package/src/lib/__tests__/instance-candidates.test.ts +94 -19
- package/src/lib/__tests__/instance-target.test.ts +9 -1
- package/src/lib/__tests__/view-assets.test.ts +114 -0
- package/src/lib/__tests__/view-port-allocation.test.ts +82 -0
- package/src/lib/admin-instance.ts +27 -2
- package/src/lib/instance-candidates.ts +3 -3
- package/src/lib/instance-target.ts +1 -0
- package/src/lib/instance.ts +0 -11
- package/src/lib/login-flow.ts +4 -41
- package/src/lib/view/assets.ts +64 -0
- package/src/lib/view/port-allocation.ts +19 -0
- package/src/lib/view/server.ts +4 -37
- package/src/setup/__tests__/instance-step.test.ts +239 -0
- package/src/setup/steps/instance.ts +141 -61
- package/studio/client/dist/assets/index-BaqEuIQJ.js +109 -0
- package/studio/client/dist/assets/index-jRdExahh.css +1 -0
- package/studio/client/dist/index.html +2 -2
- package/studio/server/agent/ask.test.ts +74 -0
- package/studio/server/agent/ask.ts +20 -22
- package/studio/server/agent/bridge/client.test.ts +49 -0
- package/studio/server/agent/bridge/client.ts +37 -0
- package/studio/server/agent/bridge/grant.test.ts +62 -0
- package/studio/server/agent/bridge/grant.ts +84 -0
- package/studio/server/agent/bridge/routes.test.ts +151 -0
- package/studio/server/agent/bridge/routes.ts +148 -0
- package/studio/server/agent/{bridge-mcp.ts → bridge/stdio.ts} +27 -21
- package/studio/server/agent/conversation.test.ts +84 -0
- package/studio/server/agent/conversation.ts +110 -0
- package/studio/server/agent/{types.ts → harness/adapter.ts} +63 -12
- package/studio/server/agent/harness/claude/adapter.test.ts +338 -0
- package/studio/server/agent/harness/claude/adapter.ts +71 -0
- package/studio/server/agent/harness/claude/ask.ts +114 -0
- package/studio/server/agent/harness/claude/capabilities.ts +27 -0
- package/studio/server/agent/harness/claude/command.ts +63 -0
- package/studio/server/agent/harness/claude/events.ts +215 -0
- package/studio/server/agent/harness/claude/loadout.ts +132 -0
- package/studio/server/agent/harness/claude/mcp.test.ts +36 -0
- package/studio/server/agent/harness/claude/mcp.ts +41 -0
- package/studio/server/agent/harness/claude/skills.ts +43 -0
- package/studio/server/agent/harness/codex/adapter.test.ts +269 -0
- package/studio/server/agent/harness/codex/adapter.ts +116 -0
- package/studio/server/agent/harness/codex/ask.test.ts +176 -0
- package/studio/server/agent/harness/codex/ask.ts +181 -0
- package/studio/server/agent/harness/codex/command.test.ts +69 -0
- package/studio/server/agent/harness/codex/command.ts +43 -0
- package/studio/server/agent/harness/codex/events.test.ts +94 -0
- package/studio/server/agent/harness/codex/events.ts +132 -0
- package/studio/server/agent/harness/codex/exec.ts +113 -0
- package/studio/server/agent/harness/codex/loadout.ts +63 -0
- package/studio/server/agent/harness/codex/mcp.test.ts +32 -0
- package/studio/server/agent/harness/codex/mcp.ts +27 -0
- package/studio/server/agent/harness/codex/models.test.ts +167 -0
- package/studio/server/agent/harness/codex/models.ts +211 -0
- package/studio/server/agent/harness/codex/skills.ts +33 -0
- package/studio/server/agent/harness/gateway/config.test.ts +98 -0
- package/studio/server/{state/harness-gateway.ts → agent/harness/gateway/config.ts} +30 -8
- package/studio/server/agent/harness/gateway/token.test.ts +133 -0
- package/studio/server/agent/harness/gateway/token.ts +166 -0
- package/studio/server/agent/harness/mock/adapter.ts +190 -0
- package/studio/server/agent/harness/mock/domain-edit.ts +41 -0
- package/studio/server/agent/harness/process.test.ts +46 -0
- package/studio/server/agent/harness/process.ts +104 -0
- package/studio/server/agent/harness/registry.ts +37 -0
- package/studio/server/agent/harness/selection.test.ts +75 -0
- package/studio/server/agent/harness/selection.ts +77 -0
- package/studio/server/agent/harness/skills.test.ts +95 -0
- package/studio/server/agent/harness/skills.ts +100 -0
- package/studio/server/agent/layout.test.ts +95 -0
- package/studio/server/agent/notify.ts +12 -0
- package/studio/server/agent/{schema-map.ts → prompts/anchors.ts} +2 -2
- package/studio/server/agent/prompts/ask.ts +34 -0
- package/studio/server/agent/{prompt.ts → prompts/system.ts} +3 -107
- package/studio/server/agent/prompts/turn.ts +57 -0
- package/studio/server/agent/routes.test.ts +176 -0
- package/studio/server/agent/routes.ts +214 -0
- package/studio/server/agent/run/completion.ts +193 -0
- package/studio/server/agent/run/coordinator.test.ts +337 -0
- package/studio/server/agent/run/coordinator.ts +104 -0
- package/studio/server/agent/run/live-state.ts +73 -0
- package/studio/server/agent/run/preparation.ts +162 -0
- package/studio/server/agent/run/transcript.test.ts +47 -0
- package/studio/server/agent/run/transcript.ts +30 -0
- package/studio/server/agent/run/usage.test.ts +46 -0
- package/studio/server/{state → agent/run}/usage.ts +4 -4
- package/studio/server/agent/stream.ts +37 -0
- package/studio/server/agent/{session-id.ts → telemetry.ts} +1 -1
- package/studio/server/api-agent-loadout.test.ts +69 -0
- package/studio/server/api.ts +3 -155
- package/studio/server/index.ts +6 -5
- package/studio/server/state/comments.test.ts +29 -0
- package/studio/server/state/comments.ts +3 -3
- package/studio/server/state/settings.test.ts +78 -0
- package/studio/server/state/settings.ts +28 -1
- package/studio/shared/agent-effort.test.ts +12 -0
- package/studio/shared/agent-effort.ts +15 -0
- package/studio/shared/agent-models.test.ts +9 -0
- package/studio/shared/agent-models.ts +14 -0
- package/studio/shared/settings-values.test.ts +18 -0
- package/studio/shared/settings-values.ts +20 -0
- package/studio/shared/types.ts +58 -7
- package/src/connect-core.test.ts +0 -42
- package/src/connect-core.ts +0 -53
- package/studio/client/dist/assets/index-CyN5G8IA.js +0 -109
- package/studio/client/dist/assets/index-DKKMHBBC.css +0 -1
- package/studio/server/agent/bridge.ts +0 -188
- package/studio/server/agent/claude.ts +0 -678
- package/studio/server/agent/mock.ts +0 -186
- package/studio/server/agent/registry.ts +0 -29
- package/studio/server/agent/runner.ts +0 -487
- package/studio/server/state/harness-token.ts +0 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Comment, ContextItem, DocMeta, SchemaIR, SchemaOverlay } from '../../../shared/types'
|
|
2
|
+
|
|
3
|
+
import { buildCopyMarkdown } from '../../state/copy'
|
|
4
|
+
import { resolveThreadAnchors } from './anchors'
|
|
5
|
+
|
|
6
|
+
export interface TurnParts {
|
|
7
|
+
origin: string
|
|
8
|
+
root: string
|
|
9
|
+
schemaHash: string
|
|
10
|
+
awaitingThreads: Comment[]
|
|
11
|
+
userContext: ContextItem[]
|
|
12
|
+
autoContext: ContextItem[]
|
|
13
|
+
documents: DocMeta[]
|
|
14
|
+
firstTurn: boolean
|
|
15
|
+
message?: string
|
|
16
|
+
ir: SchemaIR | null
|
|
17
|
+
overlay?: SchemaOverlay
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Build the handoff payload for one new or follow-up turn. */
|
|
21
|
+
export function buildTurnPrompt(parts: TurnParts): string {
|
|
22
|
+
const body = buildCopyMarkdown({
|
|
23
|
+
origin: parts.origin,
|
|
24
|
+
root: parts.root,
|
|
25
|
+
schemaHash: parts.schemaHash,
|
|
26
|
+
openComments: parts.awaitingThreads,
|
|
27
|
+
userContext: parts.userContext,
|
|
28
|
+
autoContext: parts.autoContext,
|
|
29
|
+
documents: parts.documents,
|
|
30
|
+
})
|
|
31
|
+
const hasThreads = parts.awaitingThreads.length > 0
|
|
32
|
+
const header = parts.firstTurn
|
|
33
|
+
? hasThreads
|
|
34
|
+
? '> New session. The thread pointers and context below are your orientation — implement the open threads and reply by id.'
|
|
35
|
+
: '> New session. Follow the direct instruction below; use the context below and read schema/ when needed.'
|
|
36
|
+
: hasThreads
|
|
37
|
+
? '> Follow-up turn in the SAME session. The schema files are current (incl. your prior edits); the threads below were added or updated since your last reply — implement and answer them.'
|
|
38
|
+
: '> Follow-up turn in the SAME session. The schema files are current (incl. your prior edits). Follow the direct instruction below.'
|
|
39
|
+
const anchors = resolveThreadAnchors(parts.awaitingThreads, parts.overlay)
|
|
40
|
+
const message = parts.message?.trim()
|
|
41
|
+
const instruction = message ? ['', '## Direct instruction', '', message] : []
|
|
42
|
+
return [header, ...instruction, '', anchors, '', body]
|
|
43
|
+
.filter((part) => part !== undefined)
|
|
44
|
+
.join('\n')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Build the minimal nudge for a surviving interrupted conversation. */
|
|
48
|
+
export function buildResumePrompt(): string {
|
|
49
|
+
return [
|
|
50
|
+
'> Resuming the SAME session. Your previous turn was cut off when Domain Studio',
|
|
51
|
+
'> restarted — nothing else has changed. Pick up exactly where you left off.',
|
|
52
|
+
'',
|
|
53
|
+
'Continue and finish what you were doing, then make sure every open thread gets an',
|
|
54
|
+
'answer through the usual channel (the domain-studio MCP tools, or the final',
|
|
55
|
+
'machine-state ```json``` block). Keep going from where you stopped — do not restart.',
|
|
56
|
+
].join('\n')
|
|
57
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { afterEach, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import type { AskResult } from './harness/adapter'
|
|
7
|
+
|
|
8
|
+
import { registerDomain, unregisterDomain } from '../domain'
|
|
9
|
+
import { getHarnessById } from './harness/registry'
|
|
10
|
+
import { handleAgentRoute } from './routes'
|
|
11
|
+
import { NdjsonChannel } from './stream'
|
|
12
|
+
|
|
13
|
+
const roots: string[] = []
|
|
14
|
+
const domainIds: string[] = []
|
|
15
|
+
const previousHarness = process.env.DOMAIN_STUDIO_HARNESS
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
if (previousHarness === undefined) delete process.env.DOMAIN_STUDIO_HARNESS
|
|
19
|
+
else process.env.DOMAIN_STUDIO_HARNESS = previousHarness
|
|
20
|
+
while (domainIds.length) unregisterDomain(domainIds.pop()!)
|
|
21
|
+
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
function fixture() {
|
|
25
|
+
const root = mkdtempSync(join(tmpdir(), 'studio-agent-routes-'))
|
|
26
|
+
roots.push(root)
|
|
27
|
+
mkdirSync(join(root, 'schema'))
|
|
28
|
+
writeFileSync(join(root, 'astrale.config.ts'), 'export default {}\n')
|
|
29
|
+
writeFileSync(join(root, 'domain.ts'), 'export default {}\n')
|
|
30
|
+
writeFileSync(join(root, 'schema/index.ts'), 'export const Test = {}\n')
|
|
31
|
+
const handle = registerDomain(root)!
|
|
32
|
+
domainIds.push(handle.id)
|
|
33
|
+
return handle
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function route(rest: string, method = 'GET', body: any = {}, search = '') {
|
|
37
|
+
const handle = fixture()
|
|
38
|
+
const url = new URL(`http://127.0.0.1/api/domain/${handle.id}${rest}${search}`)
|
|
39
|
+
const req = new Request(url, {
|
|
40
|
+
method,
|
|
41
|
+
...(method === 'POST'
|
|
42
|
+
? {
|
|
43
|
+
headers: { 'content-type': 'application/json' },
|
|
44
|
+
body: JSON.stringify(body),
|
|
45
|
+
}
|
|
46
|
+
: {}),
|
|
47
|
+
})
|
|
48
|
+
return handleAgentRoute({ req, url, rest, body, handle, notify: () => {} })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test('owns harness status and prompt routes behind one agent boundary', async () => {
|
|
52
|
+
process.env.DOMAIN_STUDIO_HARNESS = 'mock'
|
|
53
|
+
|
|
54
|
+
const harness = await route('/agent/harness')
|
|
55
|
+
expect(harness?.status).toBe(200)
|
|
56
|
+
expect(await harness?.json()).toMatchObject({
|
|
57
|
+
id: 'mock',
|
|
58
|
+
label: 'Mock agent (free)',
|
|
59
|
+
ok: true,
|
|
60
|
+
locked: true,
|
|
61
|
+
source: 'environment',
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const prompt = await route('/agent/prompt/system')
|
|
65
|
+
expect(await prompt?.json()).toMatchObject({
|
|
66
|
+
bridge: true,
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('ignores non-agent routes and rejects unknown agent routes', async () => {
|
|
71
|
+
process.env.DOMAIN_STUDIO_HARNESS = 'mock'
|
|
72
|
+
expect(await route('/context')).toBeNull()
|
|
73
|
+
expect((await route('/agent/unknown'))?.status).toBe(404)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('rejects stale session ownership and invalid gateway writes at the route boundary', async () => {
|
|
77
|
+
process.env.DOMAIN_STUDIO_HARNESS = 'mock'
|
|
78
|
+
|
|
79
|
+
const staleSession = await route('/agent/session', 'POST', {
|
|
80
|
+
harness: 'codex',
|
|
81
|
+
sessionId: 'wrong-owner',
|
|
82
|
+
})
|
|
83
|
+
expect(staleSession?.status).toBe(400)
|
|
84
|
+
expect(await staleSession?.json()).toEqual({
|
|
85
|
+
error: 'selected harness changed from codex to mock',
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const invalidGateway = await route('/agent/harness-gateway', 'POST', {
|
|
89
|
+
action: 'set',
|
|
90
|
+
scope: 'domain',
|
|
91
|
+
config: { enabled: true, baseUrl: '', auth: { mode: 'mint' } },
|
|
92
|
+
})
|
|
93
|
+
expect(invalidGateway?.status).toBe(400)
|
|
94
|
+
expect(await invalidGateway?.json()).toEqual({
|
|
95
|
+
error: 'gateway base URL is required while enabled',
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('threads an explicit loadout refresh through to the selected harness', async () => {
|
|
100
|
+
process.env.DOMAIN_STUDIO_HARNESS = 'claude'
|
|
101
|
+
const harness = getHarnessById('claude')
|
|
102
|
+
const originalLoadout = harness.loadout
|
|
103
|
+
let refresh: boolean | undefined
|
|
104
|
+
harness.loadout = async (_root, options) => {
|
|
105
|
+
refresh = options?.refresh
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
tools: [],
|
|
109
|
+
mcpServers: [],
|
|
110
|
+
skills: [],
|
|
111
|
+
agents: [],
|
|
112
|
+
builtinCommandCount: 0,
|
|
113
|
+
probedAt: Date.now(),
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const response = await route('/agent/loadout', 'GET', {}, '?refresh=1')
|
|
119
|
+
expect(response?.status).toBe(200)
|
|
120
|
+
expect(refresh).toBe(true)
|
|
121
|
+
} finally {
|
|
122
|
+
harness.loadout = originalLoadout
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('cancels an Ask stream without enqueueing after the client closes it', async () => {
|
|
127
|
+
process.env.DOMAIN_STUDIO_HARNESS = 'mock'
|
|
128
|
+
const harness = getHarnessById('mock')
|
|
129
|
+
const originalAsk = harness.ask
|
|
130
|
+
let finishAsk!: () => void
|
|
131
|
+
const askFinished = new Promise<void>((resolve) => (finishAsk = resolve))
|
|
132
|
+
let receivedAbort = false
|
|
133
|
+
harness.ask = async (input): Promise<AskResult> => {
|
|
134
|
+
input.onDelta('first')
|
|
135
|
+
await new Promise<void>((resolve) => {
|
|
136
|
+
input.signal.addEventListener(
|
|
137
|
+
'abort',
|
|
138
|
+
() => {
|
|
139
|
+
receivedAbort = true
|
|
140
|
+
resolve()
|
|
141
|
+
},
|
|
142
|
+
{ once: true },
|
|
143
|
+
)
|
|
144
|
+
})
|
|
145
|
+
input.onDelta('late')
|
|
146
|
+
finishAsk()
|
|
147
|
+
return { text: 'firstlate', isError: false }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const response = await route('/agent/ask', 'POST', { question: 'stream briefly' })
|
|
152
|
+
const reader = response!.body!.getReader()
|
|
153
|
+
expect(new TextDecoder().decode((await reader.read()).value)).toContain('"delta":"first"')
|
|
154
|
+
await reader.cancel()
|
|
155
|
+
await askFinished
|
|
156
|
+
expect(receivedAbort).toBe(true)
|
|
157
|
+
} finally {
|
|
158
|
+
harness.ask = originalAsk
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('an NDJSON channel never enqueues after cancellation', () => {
|
|
163
|
+
const abortController = new AbortController()
|
|
164
|
+
const chunks: Uint8Array[] = []
|
|
165
|
+
const target = {
|
|
166
|
+
enqueue: (chunk: Uint8Array) => chunks.push(chunk),
|
|
167
|
+
close: () => {},
|
|
168
|
+
} as unknown as ReadableStreamDefaultController<Uint8Array>
|
|
169
|
+
const channel = new NdjsonChannel(target, abortController)
|
|
170
|
+
|
|
171
|
+
expect(channel.send({ delta: 'first' })).toBe(true)
|
|
172
|
+
channel.cancel()
|
|
173
|
+
expect(abortController.signal.aborted).toBe(true)
|
|
174
|
+
expect(channel.send({ delta: 'late' })).toBe(false)
|
|
175
|
+
expect(chunks).toHaveLength(1)
|
|
176
|
+
})
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { HarnessStatus, StudioEvent } from '../../shared/types'
|
|
2
|
+
import type { DomainHandle } from '../domain'
|
|
3
|
+
|
|
4
|
+
import { runAsk } from './ask'
|
|
5
|
+
import { handleBridge } from './bridge/routes'
|
|
6
|
+
import { inspectHarnessHealth } from './harness/adapter'
|
|
7
|
+
import {
|
|
8
|
+
clearHarnessGateway,
|
|
9
|
+
gatewayAudience,
|
|
10
|
+
getHarnessGatewayState,
|
|
11
|
+
setHarnessGateway,
|
|
12
|
+
} from './harness/gateway/config'
|
|
13
|
+
import { setHostToken } from './harness/gateway/token'
|
|
14
|
+
import { listHarnesses } from './harness/registry'
|
|
15
|
+
import {
|
|
16
|
+
getHarness,
|
|
17
|
+
getHarnessSelection,
|
|
18
|
+
resolveHarnessConfiguration,
|
|
19
|
+
setHarnessSelection,
|
|
20
|
+
} from './harness/selection'
|
|
21
|
+
import { buildSystemPrompt } from './prompts/system'
|
|
22
|
+
import {
|
|
23
|
+
cancelRun,
|
|
24
|
+
getSessionId,
|
|
25
|
+
getSnapshot,
|
|
26
|
+
isRunning,
|
|
27
|
+
resetConversation,
|
|
28
|
+
setSessionId,
|
|
29
|
+
submitRun,
|
|
30
|
+
} from './run/coordinator'
|
|
31
|
+
import { readUsage } from './run/usage'
|
|
32
|
+
import { NdjsonChannel } from './stream'
|
|
33
|
+
|
|
34
|
+
function json(data: unknown, status = 200): Response {
|
|
35
|
+
return new Response(JSON.stringify(data), {
|
|
36
|
+
status,
|
|
37
|
+
headers: { 'content-type': 'application/json' },
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const badRequest = (message: string) => json({ error: message }, 400)
|
|
42
|
+
const notFound = () => json({ error: 'not found' }, 404)
|
|
43
|
+
|
|
44
|
+
async function harnessStatus(root: string, selected = false): Promise<HarnessStatus> {
|
|
45
|
+
const selection = getHarnessSelection(root)
|
|
46
|
+
const harness = getHarness(root)
|
|
47
|
+
const health = await inspectHarnessHealth(harness)
|
|
48
|
+
return {
|
|
49
|
+
id: harness.id,
|
|
50
|
+
label: harness.label,
|
|
51
|
+
bin: health.bin ?? harness.id,
|
|
52
|
+
ok: health.ok,
|
|
53
|
+
version: health.version,
|
|
54
|
+
message: health.ok
|
|
55
|
+
? `${selected ? `Selected ${harness.label}` : 'Detected'}${health.version ? ` — ${health.version}` : ''}`
|
|
56
|
+
: (health.detail ?? `${harness.label} is not detected. Is it installed and on your PATH?`),
|
|
57
|
+
options: listHarnesses(harness.id),
|
|
58
|
+
locked: selection.locked,
|
|
59
|
+
source: selection.source,
|
|
60
|
+
capabilities: harness.capabilities,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface AgentRouteInput {
|
|
65
|
+
req: Request
|
|
66
|
+
url: URL
|
|
67
|
+
rest: string
|
|
68
|
+
body: any
|
|
69
|
+
handle: DomainHandle
|
|
70
|
+
notify: (event: StudioEvent) => void
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Own every `/agent/*` HTTP route for one Studio domain. */
|
|
74
|
+
export async function handleAgentRoute(input: AgentRouteInput): Promise<Response | null> {
|
|
75
|
+
const { req, url, rest, body, handle, notify } = input
|
|
76
|
+
if (rest !== '/agent' && !rest.startsWith('/agent/')) return null
|
|
77
|
+
const id = handle.id
|
|
78
|
+
const root = handle.root
|
|
79
|
+
|
|
80
|
+
if (rest === '/agent') {
|
|
81
|
+
if (req.method === 'GET') return json(await getSnapshot(id))
|
|
82
|
+
return badRequest('use /agent/submit or /agent/cancel')
|
|
83
|
+
}
|
|
84
|
+
if (rest === '/agent/submit' && req.method === 'POST') {
|
|
85
|
+
const result = await submitRun(handle, notify, {
|
|
86
|
+
message: typeof body.message === 'string' ? body.message : undefined,
|
|
87
|
+
resume: body.resume === true,
|
|
88
|
+
})
|
|
89
|
+
return result.error ? json({ error: result.error }) : json(result.run)
|
|
90
|
+
}
|
|
91
|
+
if (rest === '/agent/cancel' && req.method === 'POST') return json({ ok: cancelRun(id) })
|
|
92
|
+
if (rest === '/agent/reset' && req.method === 'POST') return json({ ok: resetConversation(id) })
|
|
93
|
+
if (rest === '/agent/session') {
|
|
94
|
+
if (req.method === 'GET') return json(getSessionId(id))
|
|
95
|
+
if (req.method === 'POST') {
|
|
96
|
+
const harness = getHarness(root)
|
|
97
|
+
if (body.harness !== harness.id)
|
|
98
|
+
return badRequest(
|
|
99
|
+
`selected harness changed from ${String(body.harness ?? '(unknown)')} to ${harness.id}`,
|
|
100
|
+
)
|
|
101
|
+
const ok = setSessionId(id, typeof body.sessionId === 'string' ? body.sessionId : '')
|
|
102
|
+
if (!ok) return badRequest('the session id cannot be changed while a turn is running')
|
|
103
|
+
return json(getSessionId(id))
|
|
104
|
+
}
|
|
105
|
+
return badRequest('GET or POST')
|
|
106
|
+
}
|
|
107
|
+
if (rest === '/agent/prompt/system' && req.method === 'GET')
|
|
108
|
+
return json({ bridge: true, systemPrompt: buildSystemPrompt({ bridge: true }) })
|
|
109
|
+
if (rest === '/agent/harness' && req.method === 'GET') return json(await harnessStatus(root))
|
|
110
|
+
if (rest === '/agent/harness' && req.method === 'POST') {
|
|
111
|
+
if (isRunning(id)) return badRequest('the harness cannot be changed while a turn is running')
|
|
112
|
+
try {
|
|
113
|
+
setHarnessSelection(root, String(body.id ?? ''))
|
|
114
|
+
return json(await harnessStatus(root, true))
|
|
115
|
+
} catch (error) {
|
|
116
|
+
return badRequest(String((error as Error)?.message ?? error))
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (rest === '/agent/loadout' && req.method === 'GET') {
|
|
120
|
+
const harness = getHarness(root)
|
|
121
|
+
if (!harness.loadout)
|
|
122
|
+
return json({
|
|
123
|
+
ok: false,
|
|
124
|
+
detail: `${harness.label} does not expose a loadout`,
|
|
125
|
+
tools: [],
|
|
126
|
+
mcpServers: [],
|
|
127
|
+
skills: [],
|
|
128
|
+
agents: [],
|
|
129
|
+
builtinCommandCount: 0,
|
|
130
|
+
probedAt: Date.now(),
|
|
131
|
+
})
|
|
132
|
+
const configuration = await resolveHarnessConfiguration(root, harness)
|
|
133
|
+
if (!configuration.ok)
|
|
134
|
+
return json({
|
|
135
|
+
ok: false,
|
|
136
|
+
detail: `model gateway auth failed — ${configuration.error}`,
|
|
137
|
+
tools: [],
|
|
138
|
+
mcpServers: [],
|
|
139
|
+
skills: [],
|
|
140
|
+
agents: [],
|
|
141
|
+
builtinCommandCount: 0,
|
|
142
|
+
probedAt: Date.now(),
|
|
143
|
+
})
|
|
144
|
+
const { env, model } = configuration.configuration
|
|
145
|
+
return json(
|
|
146
|
+
await harness.loadout(root, {
|
|
147
|
+
env,
|
|
148
|
+
model,
|
|
149
|
+
refresh: url.searchParams.get('refresh') === '1',
|
|
150
|
+
}),
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
if (rest === '/agent/harness-gateway') {
|
|
154
|
+
if (req.method === 'GET') return json(getHarnessGatewayState(root))
|
|
155
|
+
if (req.method === 'POST') {
|
|
156
|
+
const scope = body.scope === 'global' ? 'global' : 'domain'
|
|
157
|
+
if (body.action === 'set') {
|
|
158
|
+
try {
|
|
159
|
+
return json(setHarnessGateway(root, { scope, config: body.config ?? {} }))
|
|
160
|
+
} catch (error) {
|
|
161
|
+
return badRequest(String((error as Error)?.message ?? error))
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (body.action === 'clear') return json(clearHarnessGateway(root, scope))
|
|
165
|
+
return badRequest('unknown harness-gateway action')
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (rest === '/agent/harness-gateway/host-token' && req.method === 'POST') {
|
|
169
|
+
const audience = gatewayAudience(root)
|
|
170
|
+
if (!audience) return badRequest('no gateway base URL configured for this domain')
|
|
171
|
+
return json({ ok: setHostToken(audience, String(body.token ?? '')) })
|
|
172
|
+
}
|
|
173
|
+
if (rest === '/agent/usage' && req.method === 'GET') return json(readUsage(root))
|
|
174
|
+
if (rest === '/agent/skill' && req.method === 'GET') {
|
|
175
|
+
const command = url.searchParams.get('command')
|
|
176
|
+
const harness = getHarness(root)
|
|
177
|
+
if (!command || !harness.skillContent) return notFound()
|
|
178
|
+
const content = await harness.skillContent(root, command)
|
|
179
|
+
return content ? json(content) : notFound()
|
|
180
|
+
}
|
|
181
|
+
if (rest === '/agent/ask' && req.method === 'POST') {
|
|
182
|
+
const controller = new AbortController()
|
|
183
|
+
req.signal?.addEventListener('abort', () => controller.abort(), { once: true })
|
|
184
|
+
let channel: NdjsonChannel | undefined
|
|
185
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
186
|
+
async start(target) {
|
|
187
|
+
channel = new NdjsonChannel(target, controller)
|
|
188
|
+
try {
|
|
189
|
+
const result = await runAsk(handle, body, controller.signal, (delta) =>
|
|
190
|
+
channel!.send({ delta }),
|
|
191
|
+
)
|
|
192
|
+
channel.send(
|
|
193
|
+
result.isError ? { error: result.errorMessage || 'ask failed' } : { done: result.text },
|
|
194
|
+
)
|
|
195
|
+
} catch (error: any) {
|
|
196
|
+
channel.send({ error: String(error?.message ?? error) })
|
|
197
|
+
} finally {
|
|
198
|
+
channel.close()
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
cancel() {
|
|
202
|
+
channel?.cancel()
|
|
203
|
+
if (!channel) controller.abort()
|
|
204
|
+
},
|
|
205
|
+
})
|
|
206
|
+
return new Response(stream, {
|
|
207
|
+
headers: { 'content-type': 'application/x-ndjson', 'cache-control': 'no-cache' },
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
if (rest.startsWith('/agent/bridge/'))
|
|
211
|
+
return handleBridge(handle, rest.slice('/agent/bridge/'.length), req, body)
|
|
212
|
+
|
|
213
|
+
return notFound()
|
|
214
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
import type { AgentEvent, StudioEvent } from '../../../shared/types'
|
|
4
|
+
import type { PreparedRun } from './preparation'
|
|
5
|
+
|
|
6
|
+
import { mergeReply } from '../../state/comments'
|
|
7
|
+
import { clearConversation, saveConversation } from '../conversation'
|
|
8
|
+
import { emitStudioEvent } from '../notify'
|
|
9
|
+
import { releaseController } from './live-state'
|
|
10
|
+
import { persistRun } from './transcript'
|
|
11
|
+
import { recordRun } from './usage'
|
|
12
|
+
|
|
13
|
+
function stripMachineState(text: string): string {
|
|
14
|
+
return text
|
|
15
|
+
.replace(/```(?:json)?\s*[\s\S]*?```/g, (block) =>
|
|
16
|
+
/"comments"|"schemaVersion"/.test(block) ? '' : block,
|
|
17
|
+
)
|
|
18
|
+
.trim()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Execute and settle one prepared agent run. */
|
|
22
|
+
export async function completeRun(
|
|
23
|
+
prepared: PreparedRun,
|
|
24
|
+
controller: AbortController,
|
|
25
|
+
notify: (event: StudioEvent) => void,
|
|
26
|
+
): Promise<void> {
|
|
27
|
+
const {
|
|
28
|
+
domainId,
|
|
29
|
+
root,
|
|
30
|
+
harness,
|
|
31
|
+
settings,
|
|
32
|
+
session,
|
|
33
|
+
resume,
|
|
34
|
+
schemaHash,
|
|
35
|
+
model,
|
|
36
|
+
effort,
|
|
37
|
+
harnessEnv,
|
|
38
|
+
bridge,
|
|
39
|
+
run,
|
|
40
|
+
promptSnapshot,
|
|
41
|
+
} = prepared
|
|
42
|
+
const pushEvent = (event: Omit<AgentEvent, 'id' | 'ts'>) => {
|
|
43
|
+
let text = event.text
|
|
44
|
+
if (event.kind === 'message') {
|
|
45
|
+
text = stripMachineState(text)
|
|
46
|
+
if (!text) return
|
|
47
|
+
}
|
|
48
|
+
const stored: AgentEvent = {
|
|
49
|
+
id: randomUUID(),
|
|
50
|
+
ts: new Date().toISOString(),
|
|
51
|
+
...event,
|
|
52
|
+
text,
|
|
53
|
+
}
|
|
54
|
+
run.events.push(stored)
|
|
55
|
+
emitStudioEvent(notify, { type: 'agent-event', domainId, runId: run.id, event: stored })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let bridgeReplies = 0
|
|
59
|
+
const liveByComment = new Map<string, Set<string>>()
|
|
60
|
+
bridge.onReply((commentId, text) => {
|
|
61
|
+
bridgeReplies += 1
|
|
62
|
+
if (!liveByComment.has(commentId)) liveByComment.set(commentId, new Set())
|
|
63
|
+
liveByComment.get(commentId)!.add(text.trim())
|
|
64
|
+
pushEvent({ kind: 'reply', text, commentId })
|
|
65
|
+
})
|
|
66
|
+
bridge.onProgress((text) => pushEvent({ kind: 'status', text }))
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const runTurn = (sessionId: string | undefined, firstTurn: boolean) => {
|
|
70
|
+
const prompt = promptSnapshot(sessionId, firstTurn)
|
|
71
|
+
run.prompt = prompt
|
|
72
|
+
emitStudioEvent(notify, { type: 'agent-run', domainId, run })
|
|
73
|
+
return harness.run({
|
|
74
|
+
root,
|
|
75
|
+
prompt: prompt.turnPrompt,
|
|
76
|
+
appendSystemPrompt: prompt.systemPrompt,
|
|
77
|
+
sessionId,
|
|
78
|
+
model,
|
|
79
|
+
effort,
|
|
80
|
+
access: settings.agentAccess,
|
|
81
|
+
mcpServers: bridge.mcpServers,
|
|
82
|
+
env: harnessEnv,
|
|
83
|
+
signal: controller.signal,
|
|
84
|
+
onEvent: pushEvent,
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const resumeEventStart = run.events.length
|
|
89
|
+
const resumeBridgeReplies = bridgeReplies
|
|
90
|
+
let result = await runTurn(resume, !resume)
|
|
91
|
+
let conversationTurns = resume ? (session.turns ?? 0) : 0
|
|
92
|
+
if (resume && result.resumeRejected && !controller.signal.aborted) {
|
|
93
|
+
clearConversation(root, harness.id)
|
|
94
|
+
conversationTurns = 0
|
|
95
|
+
run.sessionId = undefined
|
|
96
|
+
run.resumed = false
|
|
97
|
+
const observableActivity =
|
|
98
|
+
bridgeReplies > resumeBridgeReplies ||
|
|
99
|
+
run.events
|
|
100
|
+
.slice(resumeEventStart)
|
|
101
|
+
.some(
|
|
102
|
+
(event) => event.kind === 'message' || event.kind === 'tool' || event.kind === 'reply',
|
|
103
|
+
)
|
|
104
|
+
if (observableActivity) {
|
|
105
|
+
result = {
|
|
106
|
+
...result,
|
|
107
|
+
sessionId: undefined,
|
|
108
|
+
isError: true,
|
|
109
|
+
errorMessage:
|
|
110
|
+
'the previous conversation was rejected after observable activity — Studio did not retry automatically to avoid duplicating work',
|
|
111
|
+
}
|
|
112
|
+
pushEvent({
|
|
113
|
+
kind: 'status',
|
|
114
|
+
text: 'resume was not retried because the rejected attempt had already produced observable activity',
|
|
115
|
+
})
|
|
116
|
+
} else {
|
|
117
|
+
pushEvent({
|
|
118
|
+
kind: 'status',
|
|
119
|
+
text: 'previous conversation was no longer available — started a new one',
|
|
120
|
+
})
|
|
121
|
+
result = await runTurn(undefined, true)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
run.sessionId = result.sessionId ?? run.sessionId
|
|
126
|
+
run.costUsd = result.costUsd
|
|
127
|
+
run.tokens = result.tokens
|
|
128
|
+
run.numTurns = result.numTurns
|
|
129
|
+
run.liveReplies = bridgeReplies
|
|
130
|
+
if (bridgeReplies > 0 && !controller.signal.aborted)
|
|
131
|
+
pushEvent({
|
|
132
|
+
kind: 'status',
|
|
133
|
+
text: `replied to ${bridgeReplies} thread${bridgeReplies === 1 ? '' : 's'} live`,
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
let replyError: string | undefined
|
|
137
|
+
if (
|
|
138
|
+
!controller.signal.aborted &&
|
|
139
|
+
!result.isError &&
|
|
140
|
+
result.finalText &&
|
|
141
|
+
result.finalText.trim()
|
|
142
|
+
) {
|
|
143
|
+
try {
|
|
144
|
+
const merged = mergeReply(root, schemaHash, result.finalText, {
|
|
145
|
+
skipByComment: liveByComment,
|
|
146
|
+
})
|
|
147
|
+
run.merge = merged
|
|
148
|
+
if (merged.merged || merged.closed)
|
|
149
|
+
pushEvent({
|
|
150
|
+
kind: 'status',
|
|
151
|
+
text: `merged ${merged.merged} repl${merged.merged === 1 ? 'y' : 'ies'}, closed ${merged.closed}`,
|
|
152
|
+
})
|
|
153
|
+
emitStudioEvent(notify, { type: 'comments', domainId })
|
|
154
|
+
} catch (error: any) {
|
|
155
|
+
if (/```json/i.test(result.finalText)) {
|
|
156
|
+
replyError = `agent reply block was malformed JSON — reply not merged (${String(error?.message ?? error).slice(0, 80)})`
|
|
157
|
+
pushEvent({ kind: 'error', text: replyError })
|
|
158
|
+
} else if (bridgeReplies === 0) {
|
|
159
|
+
pushEvent({ kind: 'status', text: 'no machine-state reply block in the final message' })
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (controller.signal.aborted) run.status = 'canceled'
|
|
165
|
+
else if (result.isError) {
|
|
166
|
+
run.status = 'failed'
|
|
167
|
+
run.error = result.errorMessage
|
|
168
|
+
pushEvent({ kind: 'error', text: result.errorMessage ?? 'agent error' })
|
|
169
|
+
} else if (replyError) {
|
|
170
|
+
run.status = 'failed'
|
|
171
|
+
run.error = replyError
|
|
172
|
+
} else run.status = 'succeeded'
|
|
173
|
+
|
|
174
|
+
if (run.status === 'succeeded' && result.sessionId)
|
|
175
|
+
saveConversation(root, harness.id, {
|
|
176
|
+
sessionId: result.sessionId,
|
|
177
|
+
turns: conversationTurns + 1,
|
|
178
|
+
updatedAt: new Date().toISOString(),
|
|
179
|
+
})
|
|
180
|
+
} catch (error: any) {
|
|
181
|
+
run.status = controller.signal.aborted ? 'canceled' : 'failed'
|
|
182
|
+
run.error = String(error?.message ?? error)
|
|
183
|
+
pushEvent({ kind: 'error', text: run.error })
|
|
184
|
+
} finally {
|
|
185
|
+
run.finishedAt = new Date().toISOString()
|
|
186
|
+
releaseController(domainId, controller)
|
|
187
|
+
bridge.dispose()
|
|
188
|
+
recordRun(root, run)
|
|
189
|
+
persistRun(root, run, true)
|
|
190
|
+
emitStudioEvent(notify, { type: 'agent-run', domainId, run })
|
|
191
|
+
emitStudioEvent(notify, { type: 'comments', domainId })
|
|
192
|
+
}
|
|
193
|
+
}
|