@sebastianandreasson/pi-autonomous-agents 0.1.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.
@@ -0,0 +1,195 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import {
3
+ appendLog,
4
+ runShellCommand,
5
+ writeTextFile,
6
+ } from './pi-repo.mjs'
7
+
8
+ function truncateForNotes(text) {
9
+ const trimmed = text.trim()
10
+ if (trimmed.length <= 300) {
11
+ return trimmed
12
+ }
13
+ return `${trimmed.slice(0, 297)}...`
14
+ }
15
+
16
+ function formatLastAgentOutput(response) {
17
+ const sections = [
18
+ `status: ${String(response.status ?? '')}`,
19
+ `sessionId: ${String(response.sessionId ?? '')}`,
20
+ `sessionFile: ${String(response.sessionFile ?? '')}`,
21
+ `notes: ${String(response.notes ?? '').trim()}`,
22
+ ]
23
+
24
+ const output = String(response.output ?? '').trim()
25
+ if (output !== '') {
26
+ sections.push('', output)
27
+ }
28
+
29
+ return `${sections.join('\n')}\n`
30
+ }
31
+
32
+ async function runMockTurn({ config, sessionId, sessionFile, prompt, reason }) {
33
+ const nextSessionId = sessionId || `mock-${randomUUID()}`
34
+ const nextSessionFile = sessionFile || `${config.piRuntimeDir}/mock-${nextSessionId}.jsonl`
35
+ const output = [
36
+ `[mock transport] ${config.agentName} session ${nextSessionId}`,
37
+ `reason: ${reason}`,
38
+ '',
39
+ 'Prompt preview:',
40
+ prompt,
41
+ '',
42
+ 'Mock mode does not edit files. Point PI_TRANSPORT=adapter at a real adapter to enable unattended work.',
43
+ ].join('\n')
44
+
45
+ await writeTextFile(config.lastAgentOutputFile, `${output}\n`)
46
+ await appendLog(config.logFile, `Mock agent turn completed for session ${nextSessionId}`)
47
+ if (config.streamTerminal) {
48
+ process.stderr.write(`[PI mock] ${reason}\n`)
49
+ process.stderr.write('[PI mock] no live agent output in mock mode\n')
50
+ }
51
+
52
+ return {
53
+ sessionId: nextSessionId,
54
+ sessionFile: nextSessionFile,
55
+ status: 'success',
56
+ exitCode: 0,
57
+ timedOut: false,
58
+ durationSeconds: 0,
59
+ output,
60
+ notes: 'Mock transport completed without repo edits.',
61
+ }
62
+ }
63
+
64
+ function parseAdapterResponse(stdout) {
65
+ const trimmed = stdout.trim()
66
+ if (trimmed === '') {
67
+ throw new Error('Adapter returned no JSON on stdout.')
68
+ }
69
+
70
+ try {
71
+ return JSON.parse(trimmed)
72
+ } catch {
73
+ const lines = trimmed.split('\n').map((line) => line.trim()).filter(Boolean)
74
+ const lastLine = lines.at(-1)
75
+ if (!lastLine) {
76
+ throw new Error('Adapter returned no parseable JSON on stdout.')
77
+ }
78
+ return JSON.parse(lastLine)
79
+ }
80
+ }
81
+
82
+ async function runAdapterTurn({ config, model, sessionId, sessionFile, prompt, iteration, retryCount, reason }) {
83
+ if (config.adapterCommand.trim() === '') {
84
+ throw new Error('PI_TRANSPORT=adapter requires PI_ADAPTER_COMMAND to be set.')
85
+ }
86
+
87
+ const request = {
88
+ sessionId,
89
+ sessionFile,
90
+ prompt,
91
+ cwd: config.cwd,
92
+ taskFile: config.taskFile,
93
+ instructionsFile: config.instructionsFile,
94
+ developerInstructionsFile: config.developerInstructionsFile,
95
+ testerInstructionsFile: config.testerInstructionsFile,
96
+ runtimeDir: config.piRuntimeDir,
97
+ piCli: config.piCli,
98
+ model: model ?? config.piModel,
99
+ tools: config.piTools,
100
+ thinking: config.piThinking,
101
+ noExtensions: config.piNoExtensions,
102
+ noSkills: config.piNoSkills,
103
+ noPromptTemplates: config.piNoPromptTemplates,
104
+ noThemes: config.piNoThemes,
105
+ streamTerminal: config.streamTerminal,
106
+ loopRepeatThreshold: config.loopRepeatThreshold,
107
+ samePathRepeatThreshold: config.samePathRepeatThreshold,
108
+ continueAfterSeconds: config.continueAfterSeconds,
109
+ toolContinueAfterSeconds: config.toolContinueAfterSeconds,
110
+ continueMessage: config.continueMessage,
111
+ noEventTimeoutSeconds: config.noEventTimeoutSeconds,
112
+ toolNoEventTimeoutSeconds: config.toolNoEventTimeoutSeconds,
113
+ metadata: {
114
+ iteration,
115
+ retryCount,
116
+ reason,
117
+ },
118
+ }
119
+
120
+ await appendLog(
121
+ config.logFile,
122
+ `Starting adapter turn via: ${config.adapterCommand} iteration=${iteration} retry=${retryCount} reason=${reason}`
123
+ )
124
+ const result = await runShellCommand({
125
+ cwd: config.cwd,
126
+ command: config.adapterCommand,
127
+ timeoutSeconds: config.agentTimeoutSeconds,
128
+ stdinText: `${JSON.stringify(request)}\n`,
129
+ streamStderrToParent: config.streamTerminal,
130
+ })
131
+
132
+ await writeTextFile(config.lastAgentOutputFile, result.combinedOutput)
133
+
134
+ if (result.timedOut) {
135
+ await appendLog(config.logFile, 'Adapter turn timed out')
136
+ return {
137
+ sessionId: sessionId || '',
138
+ sessionFile: sessionFile || '',
139
+ status: 'timed_out',
140
+ exitCode: result.exitCode,
141
+ timedOut: true,
142
+ durationSeconds: result.durationSeconds,
143
+ output: result.combinedOutput,
144
+ notes: 'Adapter process exceeded the configured timeout.',
145
+ }
146
+ }
147
+
148
+ if (result.exitCode !== 0) {
149
+ await appendLog(config.logFile, `Adapter turn failed with exit code ${result.exitCode}`)
150
+ await writeTextFile(config.lastAgentOutputFile, result.combinedOutput)
151
+ return {
152
+ sessionId: sessionId || '',
153
+ sessionFile: sessionFile || '',
154
+ status: 'failed',
155
+ exitCode: result.exitCode,
156
+ timedOut: false,
157
+ durationSeconds: result.durationSeconds,
158
+ output: result.combinedOutput,
159
+ notes: truncateForNotes(result.combinedOutput) || 'Adapter exited non-zero.',
160
+ }
161
+ }
162
+
163
+ const response = parseAdapterResponse(result.stdout)
164
+ await writeTextFile(config.lastAgentOutputFile, formatLastAgentOutput(response))
165
+ const nextSessionId = String(response.sessionId ?? sessionId ?? '')
166
+ const nextSessionFile = String(response.sessionFile ?? sessionFile ?? '')
167
+ const status = String(response.status ?? 'success')
168
+ const output = String(response.output ?? result.combinedOutput)
169
+ const notes = String(response.notes ?? truncateForNotes(output))
170
+
171
+ await appendLog(config.logFile, `Adapter turn completed with status ${status}`)
172
+
173
+ return {
174
+ sessionId: nextSessionId,
175
+ sessionFile: nextSessionFile,
176
+ status,
177
+ exitCode: result.exitCode,
178
+ timedOut: false,
179
+ durationSeconds: result.durationSeconds,
180
+ output,
181
+ notes,
182
+ }
183
+ }
184
+
185
+ export async function runAgentTurn(args) {
186
+ if (args.config.transport === 'mock') {
187
+ return await runMockTurn(args)
188
+ }
189
+
190
+ if (args.config.transport === 'adapter') {
191
+ return await runAdapterTurn(args)
192
+ }
193
+
194
+ throw new Error(`Unsupported PI transport "${args.config.transport}". Expected "mock" or "adapter".`)
195
+ }
@@ -0,0 +1,296 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import process from 'node:process'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url))
7
+ const packageRoot = path.resolve(scriptDir, '..')
8
+ const bundledConfigFile = path.join(packageRoot, 'pi.config.json')
9
+
10
+ function hasValue(value) {
11
+ return value !== undefined && value !== null && value !== ''
12
+ }
13
+
14
+ function normalizeString(value, fallback) {
15
+ return hasValue(value) ? String(value) : fallback
16
+ }
17
+
18
+ function readString(name, fileValue, fallback) {
19
+ const value = process.env[name]
20
+ if (value !== undefined) {
21
+ return value
22
+ }
23
+ return normalizeString(fileValue, fallback)
24
+ }
25
+
26
+ function normalizeInt(name, raw, fallback) {
27
+ if (!hasValue(raw)) {
28
+ return fallback
29
+ }
30
+
31
+ const value = Number.parseInt(String(raw), 10)
32
+ if (!Number.isFinite(value) || value < 0) {
33
+ throw new Error(`Expected ${name} to be a non-negative integer, received "${raw}"`)
34
+ }
35
+
36
+ return value
37
+ }
38
+
39
+ function readInt(name, fileValue, fallback) {
40
+ const raw = process.env[name]
41
+ if (raw !== undefined && raw !== '') {
42
+ return normalizeInt(name, raw, fallback)
43
+ }
44
+ return normalizeInt(name, fileValue, fallback)
45
+ }
46
+
47
+ function normalizeBool(name, raw, fallback) {
48
+ if (!hasValue(raw)) {
49
+ return fallback
50
+ }
51
+
52
+ if (typeof raw === 'boolean') {
53
+ return raw
54
+ }
55
+
56
+ const normalized = String(raw).toLowerCase()
57
+ if (normalized === '1' || normalized === 'true' || normalized === 'yes') {
58
+ return true
59
+ }
60
+ if (normalized === '0' || normalized === 'false' || normalized === 'no') {
61
+ return false
62
+ }
63
+
64
+ throw new Error(`Expected ${name} to be a boolean flag, received "${raw}"`)
65
+ }
66
+
67
+ function readBool(name, fileValue, fallback) {
68
+ const raw = process.env[name]
69
+ if (raw !== undefined && raw !== '') {
70
+ return normalizeBool(name, raw, fallback)
71
+ }
72
+ return normalizeBool(name, fileValue, fallback)
73
+ }
74
+
75
+ function readRepoConfig(cwd) {
76
+ const configFallback = fs.existsSync(bundledConfigFile) ? bundledConfigFile : 'pi.config.json'
77
+ const configFile = path.resolve(cwd, normalizeString(process.env.PI_CONFIG_FILE, configFallback))
78
+
79
+ if (!fs.existsSync(configFile)) {
80
+ return {
81
+ configFile,
82
+ values: {},
83
+ }
84
+ }
85
+
86
+ const raw = fs.readFileSync(configFile, 'utf8')
87
+ const parsed = JSON.parse(raw)
88
+
89
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
90
+ throw new Error(`Expected ${configFile} to contain a JSON object.`)
91
+ }
92
+
93
+ return {
94
+ configFile,
95
+ values: parsed,
96
+ }
97
+ }
98
+
99
+ function resolveFromCwd(cwd, name, fileValue, fallback) {
100
+ return path.resolve(cwd, readString(name, fileValue, fallback))
101
+ }
102
+
103
+ function resolveInstructionsFile(cwd, envName, fileValue, fallback) {
104
+ if (!hasValue(fileValue) && process.env[envName] === undefined) {
105
+ return path.resolve(cwd, fallback)
106
+ }
107
+ return resolveFromCwd(cwd, envName, fileValue, fallback)
108
+ }
109
+
110
+ function readObject(name, raw, fallback) {
111
+ const value = raw === undefined ? fallback : raw
112
+ if (value === undefined || value === null) {
113
+ return fallback
114
+ }
115
+ if (typeof value !== 'object' || Array.isArray(value)) {
116
+ throw new Error(`Expected ${name} to be an object.`)
117
+ }
118
+ return value
119
+ }
120
+
121
+ function normalizeRoleModels(raw) {
122
+ const value = readObject('roleModels', raw, {})
123
+ const normalized = {}
124
+ for (const [role, modelName] of Object.entries(value)) {
125
+ if (!hasValue(modelName)) {
126
+ continue
127
+ }
128
+ normalized[String(role)] = String(modelName)
129
+ }
130
+ return normalized
131
+ }
132
+
133
+ function resolveModelProfile(modelProfiles, modelName) {
134
+ if (!modelName || typeof modelName !== 'string') {
135
+ return null
136
+ }
137
+
138
+ const profile = modelProfiles[modelName]
139
+ if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
140
+ return null
141
+ }
142
+
143
+ const apiKey = hasValue(profile.apiKey)
144
+ ? String(profile.apiKey)
145
+ : hasValue(profile.apiKeyEnv) && process.env[String(profile.apiKeyEnv)] !== undefined
146
+ ? String(process.env[String(profile.apiKeyEnv)])
147
+ : ''
148
+
149
+ return {
150
+ name: modelName,
151
+ baseUrl: normalizeString(profile.baseUrl, ''),
152
+ apiKey,
153
+ apiKeyEnv: normalizeString(profile.apiKeyEnv, ''),
154
+ vision: normalizeBool(`${modelName}.vision`, profile.vision, false),
155
+ }
156
+ }
157
+
158
+ export function resolveRoleModelName(config, role) {
159
+ const roleName = String(role ?? '').trim()
160
+ if (roleName !== '' && hasValue(config?.roleModels?.[roleName])) {
161
+ return String(config.roleModels[roleName])
162
+ }
163
+
164
+ if (roleName === 'visualReview') {
165
+ return String(config?.visualReviewModel ?? '')
166
+ }
167
+
168
+ return String(config?.piModel ?? '')
169
+ }
170
+
171
+ export function resolveRoleModel(config, role) {
172
+ const model = resolveRoleModelName(config, role)
173
+ return {
174
+ model,
175
+ modelProfile: resolveModelProfile(config?.modelProfiles ?? {}, model),
176
+ }
177
+ }
178
+
179
+ export function loadConfig(mode = 'once') {
180
+ const cwd = process.cwd()
181
+ const repoConfig = readRepoConfig(cwd)
182
+ const file = repoConfig.values
183
+ const bundledAdapterCommand = 'pi-harness adapter'
184
+ const modelProfiles = readObject('models', file.models, {})
185
+ const roleModels = normalizeRoleModels(file.roleModels)
186
+ const piModel = readString('PI_MODEL', file.piModel, '')
187
+ const visualReviewModel = readString('PI_VISUAL_REVIEW_MODEL', file.visualReviewModel, '')
188
+ const resolvedPiModel = resolveModelProfile(modelProfiles, piModel)
189
+ const resolvedVisualReviewModel = resolveModelProfile(modelProfiles, visualReviewModel)
190
+
191
+ return {
192
+ cwd,
193
+ configFile: repoConfig.configFile,
194
+ mode: mode === 'run' ? 'run' : 'once',
195
+ transport: readString('PI_TRANSPORT', file.transport, 'adapter'),
196
+ agentName: readString('PI_AGENT_NAME', file.agentName, 'PI'),
197
+ adapterCommand: readString('PI_ADAPTER_COMMAND', file.adapterCommand, bundledAdapterCommand),
198
+ taskFile: resolveFromCwd(cwd, 'PI_TASK_FILE', file.taskFile, 'TODOS.md'),
199
+ instructionsFile: resolveInstructionsFile(cwd, 'PI_INSTRUCTIONS_FILE', file.instructionsFile, path.join(packageRoot, 'templates', 'DEVELOPER.md')),
200
+ developerInstructionsFile: resolveInstructionsFile(
201
+ cwd,
202
+ 'PI_DEVELOPER_INSTRUCTIONS_FILE',
203
+ file.developerInstructionsFile,
204
+ hasValue(file.instructionsFile)
205
+ ? String(file.instructionsFile)
206
+ : path.join(packageRoot, 'templates', 'DEVELOPER.md')
207
+ ),
208
+ testerInstructionsFile: resolveInstructionsFile(
209
+ cwd,
210
+ 'PI_TESTER_INSTRUCTIONS_FILE',
211
+ file.testerInstructionsFile,
212
+ hasValue(file.instructionsFile)
213
+ ? String(file.instructionsFile)
214
+ : path.join(packageRoot, 'templates', 'TESTER.md')
215
+ ),
216
+ logFile: resolveFromCwd(cwd, 'PI_LOG_FILE', file.logFile, 'pi.log'),
217
+ telemetryJsonl: resolveFromCwd(cwd, 'PI_TELEMETRY_JSONL', file.telemetryJsonl, 'pi_telemetry.jsonl'),
218
+ telemetryCsv: resolveFromCwd(cwd, 'PI_TELEMETRY_CSV', file.telemetryCsv, 'pi_telemetry.csv'),
219
+ stateFile: resolveFromCwd(cwd, 'PI_STATE_FILE', file.stateFile, '.pi-state.json'),
220
+ sessionFile: resolveFromCwd(cwd, 'PI_SESSION_FILE', file.sessionFile, '.pi-session-id'),
221
+ lastAgentOutputFile: resolveFromCwd(cwd, 'PI_LAST_AGENT_OUTPUT_FILE', file.lastAgentOutputFile, '.pi-last-output.txt'),
222
+ lastVerificationOutputFile: resolveFromCwd(cwd, 'PI_LAST_VERIFICATION_OUTPUT_FILE', file.lastVerificationOutputFile, '.pi-last-verification.txt'),
223
+ changedFilesFile: resolveFromCwd(cwd, 'PI_CHANGED_FILES_FILE', file.changedFilesFile, '.pi-changed-files.txt'),
224
+ piRuntimeDir: resolveFromCwd(cwd, 'PI_RUNTIME_DIR', file.piRuntimeDir, '.pi-runtime'),
225
+ piCli: readString('PI_CLI', file.piCli, 'pi'),
226
+ piModel,
227
+ piModelProfile: resolvedPiModel,
228
+ modelProfiles,
229
+ roleModels,
230
+ piTools: readString('PI_TOOLS', file.piTools, 'read,bash,edit,write,grep,find,ls'),
231
+ piThinking: readString('PI_THINKING', file.piThinking, ''),
232
+ piNoExtensions: readBool('PI_NO_EXTENSIONS', file.piNoExtensions, false),
233
+ piNoSkills: readBool('PI_NO_SKILLS', file.piNoSkills, false),
234
+ piNoPromptTemplates: readBool('PI_NO_PROMPT_TEMPLATES', file.piNoPromptTemplates, false),
235
+ piNoThemes: readBool('PI_NO_THEMES', file.piNoThemes, true),
236
+ streamTerminal: readBool('PI_STREAM_TERMINAL', file.streamTerminal, false),
237
+ loopRepeatThreshold: readInt('PI_LOOP_REPEAT_THRESHOLD', file.loopRepeatThreshold, 12),
238
+ samePathRepeatThreshold: readInt('PI_SAME_PATH_REPEAT_THRESHOLD', file.samePathRepeatThreshold, 8),
239
+ continueAfterSeconds: readInt('PI_CONTINUE_AFTER', file.continueAfterSeconds, 300),
240
+ continueMessage: readString('PI_CONTINUE_MESSAGE', file.continueMessage, 'continue'),
241
+ noEventTimeoutSeconds: readInt('PI_NO_EVENT_TIMEOUT', file.noEventTimeoutSeconds, 900),
242
+ toolContinueAfterSeconds: readInt('PI_TOOL_CONTINUE_AFTER', file.toolContinueAfterSeconds, 900),
243
+ toolNoEventTimeoutSeconds: readInt('PI_TOOL_NO_EVENT_TIMEOUT', file.toolNoEventTimeoutSeconds, 1800),
244
+ testCommand: readString('PI_TEST_CMD', file.testCommand, ''),
245
+ agentTimeoutSeconds: readInt('PI_AGENT_TIMEOUT', file.agentTimeoutSeconds, 3600),
246
+ verificationTimeoutSeconds: readInt('PI_VERIFICATION_TIMEOUT', file.verificationTimeoutSeconds, 300),
247
+ idleRetryLimit: readInt('PI_IDLE_RETRY_LIMIT', file.idleRetryLimit, 1),
248
+ noChangeRetryLimit: readInt('PI_NO_CHANGE_RETRY_LIMIT', file.noChangeRetryLimit, 1),
249
+ visualFeedbackFile: resolveFromCwd(
250
+ cwd,
251
+ 'PI_VISUAL_FEEDBACK_FILE',
252
+ file.visualFeedbackFile,
253
+ 'pi-output/visual-review/FEEDBACK.md'
254
+ ),
255
+ testerFeedbackFile: resolveFromCwd(
256
+ cwd,
257
+ 'PI_TESTER_FEEDBACK_FILE',
258
+ file.testerFeedbackFile,
259
+ 'pi-output/tester-feedback/FEEDBACK.md'
260
+ ),
261
+ testerFeedbackHistoryDir: resolveFromCwd(
262
+ cwd,
263
+ 'PI_TESTER_FEEDBACK_HISTORY_DIR',
264
+ file.testerFeedbackHistoryDir,
265
+ 'pi-output/tester-feedback/history'
266
+ ),
267
+ visualReviewHistoryDir: resolveFromCwd(
268
+ cwd,
269
+ 'PI_VISUAL_REVIEW_HISTORY_DIR',
270
+ file.visualReviewHistoryDir,
271
+ 'pi-output/visual-review/history'
272
+ ),
273
+ visualCaptureDir: resolveFromCwd(
274
+ cwd,
275
+ 'PI_VISUAL_CAPTURE_DIR',
276
+ file.visualCaptureDir,
277
+ 'pi-output/visual-capture'
278
+ ),
279
+ visualCaptureCommand: readString('PI_VISUAL_CAPTURE_CMD', file.visualCaptureCommand, ''),
280
+ visualCaptureTimeoutSeconds: readInt('PI_VISUAL_CAPTURE_TIMEOUT', file.visualCaptureTimeoutSeconds, 300),
281
+ visualReviewEnabled: readBool('PI_VISUAL_REVIEW_ENABLED', file.visualReviewEnabled, false),
282
+ visualReviewEveryNSuccesses: readInt('PI_VISUAL_REVIEW_EVERY', file.visualReviewEveryNSuccesses, 5),
283
+ visualReviewModel,
284
+ visualReviewModelProfile: resolvedVisualReviewModel,
285
+ visualReviewCommand: readString(
286
+ 'PI_VISUAL_REVIEW_COMMAND',
287
+ file.visualReviewCommand,
288
+ 'pi-harness visual-review-worker'
289
+ ),
290
+ visualReviewMaxImages: readInt('PI_VISUAL_REVIEW_MAX_IMAGES', file.visualReviewMaxImages, 8),
291
+ visualReviewTimeoutSeconds: readInt('PI_VISUAL_REVIEW_TIMEOUT', file.visualReviewTimeoutSeconds, 300),
292
+ maxIterations: readInt('PI_MAX_ITERS', file.maxIterations, 200),
293
+ sleepBetweenSeconds: readInt('PI_SLEEP_BETWEEN', file.sleepBetweenSeconds, 2),
294
+ reportLimit: readInt('PI_REPORT_LIMIT', file.reportLimit, 20),
295
+ }
296
+ }
@@ -0,0 +1,42 @@
1
+ export function shouldPersistLatestTesterFeedback(source) {
2
+ return String(source ?? '').trim() !== 'tester_commit_plan'
3
+ }
4
+
5
+ export function deriveWorkflowStatus({
6
+ developerStatus,
7
+ testerStatus,
8
+ verificationStatus,
9
+ }) {
10
+ return (
11
+ developerStatus === 'success'
12
+ && testerStatus === 'success'
13
+ && (
14
+ verificationStatus === 'passed'
15
+ || verificationStatus === 'skipped'
16
+ || verificationStatus === 'not_run'
17
+ )
18
+ )
19
+ ? 'success'
20
+ : developerStatus === 'complete'
21
+ ? 'complete'
22
+ : developerStatus !== 'success'
23
+ ? developerStatus
24
+ : testerStatus !== 'success'
25
+ ? testerStatus
26
+ : verificationStatus
27
+ }
28
+
29
+ export function deriveFinalStatusWithVisualReview({
30
+ workflowStatus,
31
+ visualStatus,
32
+ }) {
33
+ if (workflowStatus !== 'success') {
34
+ return workflowStatus
35
+ }
36
+
37
+ if (visualStatus === 'failed' || visualStatus === 'timed_out' || visualStatus === 'blocked') {
38
+ return visualStatus
39
+ }
40
+
41
+ return workflowStatus
42
+ }
@@ -0,0 +1,152 @@
1
+ function readTimeout(raw, fallback) {
2
+ const value = Number(raw)
3
+ return Number.isFinite(value) && value >= 0 ? value : fallback
4
+ }
5
+
6
+ export function resolveHeartbeatConfig(request = {}) {
7
+ const continueAfterSeconds = readTimeout(request.continueAfterSeconds, 300)
8
+ const noEventTimeoutSeconds = readTimeout(request.noEventTimeoutSeconds, 900)
9
+ const toolContinueAfterSeconds = readTimeout(request.toolContinueAfterSeconds, 900)
10
+ const toolNoEventTimeoutSeconds = readTimeout(request.toolNoEventTimeoutSeconds, 1800)
11
+
12
+ return {
13
+ continueAfterSeconds,
14
+ noEventTimeoutSeconds,
15
+ toolContinueAfterSeconds,
16
+ toolNoEventTimeoutSeconds,
17
+ }
18
+ }
19
+
20
+ export function getHeartbeatThresholds({
21
+ continueAfterSeconds,
22
+ noEventTimeoutSeconds,
23
+ toolContinueAfterSeconds,
24
+ toolNoEventTimeoutSeconds,
25
+ activeToolName,
26
+ }) {
27
+ if (activeToolName) {
28
+ return {
29
+ continueAfterSeconds: toolContinueAfterSeconds,
30
+ noEventTimeoutSeconds: toolNoEventTimeoutSeconds,
31
+ timeoutClass: 'tool_idle',
32
+ }
33
+ }
34
+
35
+ return {
36
+ continueAfterSeconds,
37
+ noEventTimeoutSeconds,
38
+ timeoutClass: 'agent_idle',
39
+ }
40
+ }
41
+
42
+ export function getActiveToolInfo(activeToolName, activeToolStartedAt, now = Date.now()) {
43
+ if (!activeToolName || !Number.isFinite(activeToolStartedAt)) {
44
+ return {
45
+ activeToolName: '',
46
+ toolRuntimeSeconds: 0,
47
+ isToolActive: false,
48
+ }
49
+ }
50
+
51
+ return {
52
+ activeToolName,
53
+ toolRuntimeSeconds: Math.max(0, Math.floor((now - activeToolStartedAt) / 1000)),
54
+ isToolActive: true,
55
+ }
56
+ }
57
+
58
+ export function getHeartbeatDecision({
59
+ now = Date.now(),
60
+ agentStarted,
61
+ agentEnded,
62
+ heartbeatTimedOut,
63
+ childExited,
64
+ lastEventAt,
65
+ continueAttempted,
66
+ activeToolName = '',
67
+ activeToolStartedAt = 0,
68
+ continueAfterSeconds,
69
+ noEventTimeoutSeconds,
70
+ toolContinueAfterSeconds,
71
+ toolNoEventTimeoutSeconds,
72
+ }) {
73
+ if (!agentStarted || agentEnded || heartbeatTimedOut || childExited) {
74
+ return { action: 'none' }
75
+ }
76
+
77
+ const idleSeconds = Math.max(0, Math.floor((now - lastEventAt) / 1000))
78
+ const { activeToolName: resolvedToolName, toolRuntimeSeconds, isToolActive } = getActiveToolInfo(
79
+ activeToolName,
80
+ activeToolStartedAt,
81
+ now
82
+ )
83
+ const thresholds = getHeartbeatThresholds({
84
+ continueAfterSeconds,
85
+ noEventTimeoutSeconds,
86
+ toolContinueAfterSeconds,
87
+ toolNoEventTimeoutSeconds,
88
+ activeToolName: resolvedToolName,
89
+ })
90
+
91
+ if (!continueAttempted && thresholds.continueAfterSeconds > 0 && idleSeconds > thresholds.continueAfterSeconds) {
92
+ return {
93
+ action: 'soft_continue',
94
+ idleSeconds,
95
+ ...thresholds,
96
+ activeToolName: resolvedToolName,
97
+ toolRuntimeSeconds,
98
+ isToolActive,
99
+ }
100
+ }
101
+
102
+ if (thresholds.noEventTimeoutSeconds > 0 && idleSeconds > thresholds.noEventTimeoutSeconds) {
103
+ return {
104
+ action: 'abort',
105
+ idleSeconds,
106
+ ...thresholds,
107
+ activeToolName: resolvedToolName,
108
+ toolRuntimeSeconds,
109
+ isToolActive,
110
+ }
111
+ }
112
+
113
+ return {
114
+ action: 'none',
115
+ idleSeconds,
116
+ ...thresholds,
117
+ activeToolName: resolvedToolName,
118
+ toolRuntimeSeconds,
119
+ isToolActive,
120
+ }
121
+ }
122
+
123
+ export function formatHeartbeatReason({
124
+ timeoutClass,
125
+ noEventTimeoutSeconds,
126
+ activeToolName,
127
+ toolRuntimeSeconds,
128
+ }) {
129
+ const parts = [
130
+ `timeout_class=${timeoutClass}`,
131
+ `no_pi_events_for=${noEventTimeoutSeconds}s`,
132
+ ]
133
+
134
+ if (activeToolName) {
135
+ parts.push(`active_tool=${activeToolName}`)
136
+ parts.push(`tool_runtime_seconds=${toolRuntimeSeconds}`)
137
+ }
138
+
139
+ return parts.join(' ')
140
+ }
141
+
142
+ export function formatHeartbeatTimeoutMessage({
143
+ noEventTimeoutSeconds,
144
+ activeToolName,
145
+ toolRuntimeSeconds,
146
+ }) {
147
+ if (activeToolName) {
148
+ return `No PI RPC events were received for ${noEventTimeoutSeconds} seconds while tool "${activeToolName}" was running (runtime ${toolRuntimeSeconds}s).`
149
+ }
150
+
151
+ return `No PI RPC events were received for ${noEventTimeoutSeconds} seconds.`
152
+ }