@bybrawe/opencode-loop 0.5.35 → 0.5.37
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/CHANGELOG.md +22 -0
- package/README.md +1 -1
- package/package.json +3 -3
- package/scripts/empty-turn-recovery-test.mjs +150 -0
- package/scripts/host-adapter-contract-test.mjs +2 -2
- package/scripts/host-goal-coexistence-canary.mjs +419 -0
- package/scripts/smoke-test.mjs +21 -0
- package/src/index.js +222 -97
- package/src/source/legacy-v1.js +29 -6
- package/src/source/opencode/host.js +23 -2
- package/src/source/runtime/empty-turn.js +46 -0
- package/src/source/runtime/executor.js +28 -0
- package/src/source/runtime/session-status.js +14 -8
package/src/source/legacy-v1.js
CHANGED
|
@@ -17,6 +17,7 @@ import { createGoalExecutionPolicy } from "./runtime/goal-policy.js"
|
|
|
17
17
|
import { createJobWorkspaceRuntime, dangerousShell } from "./runtime/job-workspace.js"
|
|
18
18
|
import { createLoopExecutor } from "./runtime/executor.js"
|
|
19
19
|
import { createGoalSteeringRuntime } from "./runtime/goal-steering.js"
|
|
20
|
+
import { findDedicatedGoalForSession } from "./runtime/companion-goal.js"
|
|
20
21
|
|
|
21
22
|
const DEFAULT_ACTIVE_GUARD_MS = 45_000
|
|
22
23
|
|
|
@@ -162,44 +163,66 @@ function steeringToolRejection(sessionID) {
|
|
|
162
163
|
}
|
|
163
164
|
}
|
|
164
165
|
|
|
166
|
+
async function dedicatedGoalToolRejection(directory, sessionID) {
|
|
167
|
+
const goal = await findDedicatedGoalForSession(directory, sessionID)
|
|
168
|
+
if (!goal || goal.status === "completed") return undefined
|
|
169
|
+
return {
|
|
170
|
+
title: "Dedicated Goal owns lifecycle",
|
|
171
|
+
output: [
|
|
172
|
+
`A dedicated OpenCode Goal is persisted for this session (status=${String(goal.status || "unknown")}).`,
|
|
173
|
+
"The opencode_loop_goal_* tools control only experimental /loop-goal jobs and will not mutate the dedicated Goal.",
|
|
174
|
+
"Use opencode_goal_progress for checkpoints, opencode_goal_wait_for_user when user/manual input is required, opencode_goal_blocked for a genuine blocker, or opencode_goal_complete for verified completion.",
|
|
175
|
+
].join("\n"),
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
165
179
|
function goalTools(defaultDirectory) {
|
|
166
180
|
return {
|
|
167
181
|
opencode_loop_goal_complete: tool({
|
|
168
|
-
description: "
|
|
182
|
+
description: "Experimental /loop-goal ONLY: mark the current OpenCode Loop experimental goal as completed. Never use this tool to control a dedicated @bybrawe/opencode-goal Goal; use opencode_goal_complete there. Use only after acceptance criteria are satisfied and you have evidence from tests, typecheck, build, or code inspection.",
|
|
169
183
|
args: {
|
|
170
184
|
summary: tool.schema.string().describe("Short human-readable summary of what was completed."),
|
|
171
185
|
evidence: tool.schema.string().describe("Concrete evidence that the goal is complete, such as commands run, passing checks, files changed, and important results."),
|
|
172
186
|
},
|
|
173
187
|
execute: async (args, context) => {
|
|
188
|
+
const directory = context.directory || defaultDirectory
|
|
189
|
+
const dedicated = await dedicatedGoalToolRejection(directory, context.sessionID)
|
|
190
|
+
if (dedicated) return dedicated
|
|
174
191
|
const steering = steeringToolRejection(context.sessionID)
|
|
175
192
|
if (steering) return steering
|
|
176
|
-
const result = await setGoalComplete(
|
|
193
|
+
const result = await setGoalComplete(directory, context.sessionID, args)
|
|
177
194
|
return { title: result.ok ? "Goal completed" : result.rejected ? "Goal completion rejected" : "Goal not found", output: result.message }
|
|
178
195
|
},
|
|
179
196
|
}),
|
|
180
197
|
opencode_loop_goal_blocked: tool({
|
|
181
|
-
description: "
|
|
198
|
+
description: "Experimental /loop-goal ONLY: mark the current OpenCode Loop experimental goal as blocked. Never use this tool for a dedicated @bybrawe/opencode-goal Goal; use opencode_goal_wait_for_user for user/manual input or opencode_goal_blocked for a genuine dedicated-Goal blocker.",
|
|
182
199
|
args: {
|
|
183
200
|
reason: tool.schema.string().describe("Why the goal is blocked."),
|
|
184
201
|
needed: tool.schema.string().describe("What user input, credential, decision, or manual action is needed to continue."),
|
|
185
202
|
},
|
|
186
203
|
execute: async (args, context) => {
|
|
204
|
+
const directory = context.directory || defaultDirectory
|
|
205
|
+
const dedicated = await dedicatedGoalToolRejection(directory, context.sessionID)
|
|
206
|
+
if (dedicated) return dedicated
|
|
187
207
|
const steering = steeringToolRejection(context.sessionID)
|
|
188
208
|
if (steering) return steering
|
|
189
|
-
const result = await setGoalBlocked(
|
|
209
|
+
const result = await setGoalBlocked(directory, context.sessionID, args)
|
|
190
210
|
return { title: result.ok ? "Goal blocked" : "Goal not found", output: result.message }
|
|
191
211
|
},
|
|
192
212
|
}),
|
|
193
213
|
opencode_loop_goal_progress: tool({
|
|
194
|
-
description: "
|
|
214
|
+
description: "Experimental /loop-goal ONLY: record meaningful progress on the current OpenCode Loop experimental goal. Never use this tool for a dedicated @bybrawe/opencode-goal Goal; use opencode_goal_progress there.",
|
|
195
215
|
args: {
|
|
196
216
|
summary: tool.schema.string().describe("What useful progress was made."),
|
|
197
217
|
next: tool.schema.string().describe("The next step toward completing the goal."),
|
|
198
218
|
},
|
|
199
219
|
execute: async (args, context) => {
|
|
220
|
+
const directory = context.directory || defaultDirectory
|
|
221
|
+
const dedicated = await dedicatedGoalToolRejection(directory, context.sessionID)
|
|
222
|
+
if (dedicated) return dedicated
|
|
200
223
|
const steering = steeringToolRejection(context.sessionID)
|
|
201
224
|
if (steering) return steering
|
|
202
|
-
const result = await setGoalProgress(
|
|
225
|
+
const result = await setGoalProgress(directory, context.sessionID, args)
|
|
203
226
|
return { title: result.ok ? "Goal progress" : "Goal not found", output: result.message }
|
|
204
227
|
},
|
|
205
228
|
}),
|
|
@@ -54,10 +54,22 @@ export function orderedSessionMessages(messages) {
|
|
|
54
54
|
.map((entry) => entry.message)
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
export function assistantMessageHasMeaningfulActivity(message) {
|
|
58
|
+
const parts = Array.isArray(message?.parts) ? message.parts : []
|
|
59
|
+
for (const part of parts) {
|
|
60
|
+
if (!part || typeof part !== "object") continue
|
|
61
|
+
if (part.type === "text" && typeof part.text === "string" && part.text.trim()) return true
|
|
62
|
+
if (["tool", "file", "patch", "artifact"].includes(String(part.type || ""))) return true
|
|
63
|
+
}
|
|
64
|
+
const info = message?.info || message || {}
|
|
65
|
+
return [info.text, info.content, info.summary].some((value) => typeof value === "string" && value.trim())
|
|
66
|
+
}
|
|
67
|
+
|
|
57
68
|
export async function activeRunCompletionFromMessages(directory, client, sessionID, active) {
|
|
58
69
|
const messages = await readRecentSessionMessages(client, sessionID, directory)
|
|
59
70
|
if (!messages) return "unknown"
|
|
60
|
-
const
|
|
71
|
+
const ordered = orderedSessionMessages(messages)
|
|
72
|
+
const tail = ordered.at(-1)
|
|
61
73
|
const info = tail?.info || tail
|
|
62
74
|
if (!info || info.role !== "assistant") return "incomplete"
|
|
63
75
|
const completed = Number(info?.time?.completed || 0)
|
|
@@ -65,7 +77,16 @@ export async function activeRunCompletionFromMessages(directory, client, session
|
|
|
65
77
|
if (!Number.isFinite(completed) || completed <= 0) return "incomplete"
|
|
66
78
|
const startedAt = Number(active?.startedAt || 0)
|
|
67
79
|
if (startedAt > 0 && completed < startedAt && (!Number.isFinite(created) || created < startedAt)) return "incomplete"
|
|
68
|
-
|
|
80
|
+
|
|
81
|
+
const relevant = ordered.filter((message) => {
|
|
82
|
+
const candidate = message?.info || message || {}
|
|
83
|
+
if (candidate.role !== "assistant") return false
|
|
84
|
+
if (startedAt <= 0) return true
|
|
85
|
+
const candidateCreated = Number(candidate?.time?.created || 0)
|
|
86
|
+
const candidateCompleted = Number(candidate?.time?.completed || 0)
|
|
87
|
+
return candidateCreated >= startedAt || candidateCompleted >= startedAt
|
|
88
|
+
})
|
|
89
|
+
return relevant.some(assistantMessageHasMeaningfulActivity) ? "completed" : "empty"
|
|
69
90
|
}
|
|
70
91
|
|
|
71
92
|
export async function resolveCompactionModel(directory, client, sessionID, preferredModel) {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { actionKind } from "../core/jobs.js"
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_MAX_EMPTY_TURNS = 2
|
|
4
|
+
|
|
5
|
+
export function guardsEmptyAssistantTurn(job) {
|
|
6
|
+
const kind = actionKind(job?.action, job || {})
|
|
7
|
+
return kind === "prompt" || kind === "goal"
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function emptyTurnLimit(job) {
|
|
11
|
+
const configured = Number(job?.maxEmptyTurns || 0)
|
|
12
|
+
if (Number.isFinite(configured) && configured > 0) return Math.max(1, Math.floor(configured))
|
|
13
|
+
return DEFAULT_MAX_EMPTY_TURNS
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function refundEmptyAssistantTurn(job, active = {}, timestamp = Date.now()) {
|
|
17
|
+
const chargedCount = Number(active?.job?.runCount ?? job?.runCount ?? 0)
|
|
18
|
+
const currentCount = Number(job?.runCount || 0)
|
|
19
|
+
if (chargedCount > 0 && currentCount >= chargedCount) job.runCount = Math.max(0, currentCount - 1)
|
|
20
|
+
|
|
21
|
+
if (Number.isFinite(Number(active?.previousLastRunAt))) job.lastRunAt = Number(active.previousLastRunAt)
|
|
22
|
+
if (active?.disabledByMaxRuns && Number(job?.maxRuns || 0) > 0 && Number(job?.runCount || 0) < Number(job.maxRuns)) {
|
|
23
|
+
job.enabled = true
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
job.emptyTurnCount = Number(job.emptyTurnCount || 0) + 1
|
|
27
|
+
job.lastEmptyTurnAt = Number(timestamp) || Date.now()
|
|
28
|
+
job.lastFailureReason = "empty_turn"
|
|
29
|
+
|
|
30
|
+
const limit = emptyTurnLimit(job)
|
|
31
|
+
const paused = job.emptyTurnCount >= limit
|
|
32
|
+
if (paused) {
|
|
33
|
+
job.paused = true
|
|
34
|
+
delete job.runNowRequestedAt
|
|
35
|
+
} else {
|
|
36
|
+
job.runNowRequestedAt = Math.max(1, Number(timestamp) || Date.now())
|
|
37
|
+
}
|
|
38
|
+
return { job, paused, count: job.emptyTurnCount, limit }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function clearEmptyAssistantTurnStreak(job) {
|
|
42
|
+
if (!job) return job
|
|
43
|
+
job.emptyTurnCount = 0
|
|
44
|
+
if (job.lastFailureReason === "empty_turn") delete job.lastFailureReason
|
|
45
|
+
return job
|
|
46
|
+
}
|
|
@@ -11,6 +11,7 @@ import { createActionDispatcher } from "./action-dispatch.js"
|
|
|
11
11
|
import { createRunFinalizationRuntime } from "./run-finalization.js"
|
|
12
12
|
import { createRunAdmissionRuntime } from "./run-admission.js"
|
|
13
13
|
import { isTransientNetworkError, networkRetryDelayMs, refundInfrastructureRun } from "./network-recovery.js"
|
|
14
|
+
import { guardsEmptyAssistantTurn, refundEmptyAssistantTurn, clearEmptyAssistantTurnStreak } from "./empty-turn.js"
|
|
14
15
|
|
|
15
16
|
const DEFAULT_ACTIVE_GUARD_MS = 45_000
|
|
16
17
|
const DEFAULT_BUSY_RETRY_MS = 5_000
|
|
@@ -77,6 +78,7 @@ export function createLoopExecutor(options = {}) {
|
|
|
77
78
|
updateSessionStatusFromEvent,
|
|
78
79
|
staleActiveRun,
|
|
79
80
|
canFinalizeActiveRun,
|
|
81
|
+
activeRunCompletion,
|
|
80
82
|
sessionStatusType,
|
|
81
83
|
sessionIsIdle,
|
|
82
84
|
markSessionStatus,
|
|
@@ -267,6 +269,7 @@ export function createLoopExecutor(options = {}) {
|
|
|
267
269
|
const active = activeRuns.get(sessionID)
|
|
268
270
|
if (!active) return
|
|
269
271
|
if (!await canFinalizeActiveRun(directory, client, sessionID, active, finalizeOptions)) return false
|
|
272
|
+
const completion = await activeRunCompletion(directory, client, sessionID, active)
|
|
270
273
|
const recoveredStale = staleActiveRun(sessionID)
|
|
271
274
|
if (active.compactionOnly) {
|
|
272
275
|
const pending = compactionRuntime.getPending(sessionID)
|
|
@@ -287,6 +290,31 @@ export function createLoopExecutor(options = {}) {
|
|
|
287
290
|
let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId)
|
|
288
291
|
if (!job) return
|
|
289
292
|
job.lastFinishedAt = now()
|
|
293
|
+
|
|
294
|
+
if (completion === "empty" && guardsEmptyAssistantTurn(job)) {
|
|
295
|
+
const empty = refundEmptyAssistantTurn(job, active, now())
|
|
296
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
297
|
+
await writeState(directory, sessionID, state)
|
|
298
|
+
await appendLoopLog(directory, "empty-assistant-turn", {
|
|
299
|
+
sessionID,
|
|
300
|
+
job: job.name || job.id,
|
|
301
|
+
count: empty.count,
|
|
302
|
+
limit: empty.limit,
|
|
303
|
+
paused: empty.paused,
|
|
304
|
+
refunded: true,
|
|
305
|
+
})
|
|
306
|
+
if (empty.paused) {
|
|
307
|
+
await notifyJob(directory, job, "empty_turn")
|
|
308
|
+
await toast(client, "Loop paused after " + empty.count + " consecutive completed assistant turns with no visible output or tool activity. Resume after changing the model/prompt or use /loop-resume.", "warning")
|
|
309
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
310
|
+
} else {
|
|
311
|
+
await toast(client, "Loop received an empty completed assistant turn; the logical run was refunded and will retry once.", "warning")
|
|
312
|
+
await scheduleDueWork(directory, client, sessionID, busyRetryMs)
|
|
313
|
+
}
|
|
314
|
+
return true
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (completion === "completed") clearEmptyAssistantTurnStreak(job)
|
|
290
318
|
if (recoveredStale) {
|
|
291
319
|
await appendLoopLog(directory, "active-stale-recovery", {
|
|
292
320
|
sessionID,
|
|
@@ -24,6 +24,10 @@ function nonNegativeNumber(value, fallback) {
|
|
|
24
24
|
return Number.isFinite(number) && number >= 0 ? number : fallback
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
function settledAssistantCompletion(value) {
|
|
28
|
+
return value === "completed" || value === "empty"
|
|
29
|
+
}
|
|
30
|
+
|
|
27
31
|
export function createSessionStatusRuntime(options = {}) {
|
|
28
32
|
const activeRuns = options.activeRuns
|
|
29
33
|
if (!(activeRuns instanceof Map)) throw new TypeError("createSessionStatusRuntime requires activeRuns Map")
|
|
@@ -114,7 +118,7 @@ export function createSessionStatusRuntime(options = {}) {
|
|
|
114
118
|
const completion = options.forceStale
|
|
115
119
|
? await activeRunCompletionFromMessages(directory, client, sessionID, active)
|
|
116
120
|
: undefined
|
|
117
|
-
if (completion
|
|
121
|
+
if (settledAssistantCompletion(completion)) return true
|
|
118
122
|
if (!options.requireIdle) return completion === "unknown" && staleActiveRun(sessionID)
|
|
119
123
|
|
|
120
124
|
const cached = sessionStatuses.get(sessionID)
|
|
@@ -142,7 +146,7 @@ export function createSessionStatusRuntime(options = {}) {
|
|
|
142
146
|
if (liveType !== "busy") return false
|
|
143
147
|
if (!seenAt || now() - seenAt < sessionStatusCacheMs) return false
|
|
144
148
|
const completion = await activeRunCompletionFromMessages(directory, client, sessionID, { startedAt: 0 })
|
|
145
|
-
if (completion
|
|
149
|
+
if (!settledAssistantCompletion(completion)) return false
|
|
146
150
|
markSessionStatus(sessionID, "idle")
|
|
147
151
|
await appendLoopLog(directory, "status-message-idle-recovery", {
|
|
148
152
|
sessionID,
|
|
@@ -177,7 +181,7 @@ export function createSessionStatusRuntime(options = {}) {
|
|
|
177
181
|
const active = activeRuns.get(sessionID)
|
|
178
182
|
if (active) {
|
|
179
183
|
const completion = await activeRunCompletionFromMessages(directory, client, sessionID, active)
|
|
180
|
-
if (completion
|
|
184
|
+
if (settledAssistantCompletion(completion) || (live.type === "busy" && completion === "unknown" && staleActiveRun(sessionID))) {
|
|
181
185
|
markSessionStatus(sessionID, "idle")
|
|
182
186
|
const logDetails = {
|
|
183
187
|
sessionID,
|
|
@@ -185,11 +189,12 @@ export function createSessionStatusRuntime(options = {}) {
|
|
|
185
189
|
startedAt: active.startedAt,
|
|
186
190
|
...(completion === "completed" ? {} : { staleStatus: live.type }),
|
|
187
191
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
completion === "completed"
|
|
191
|
-
|
|
192
|
-
|
|
192
|
+
const recoveryEvent = completion === "empty"
|
|
193
|
+
? "status-message-empty-recovery"
|
|
194
|
+
: completion === "completed"
|
|
195
|
+
? "status-message-complete-recovery"
|
|
196
|
+
: "status-stale-recovery"
|
|
197
|
+
await appendLoopLog(directory, recoveryEvent, logDetails)
|
|
193
198
|
return "idle"
|
|
194
199
|
}
|
|
195
200
|
}
|
|
@@ -214,6 +219,7 @@ export function createSessionStatusRuntime(options = {}) {
|
|
|
214
219
|
staleActiveRun,
|
|
215
220
|
canFinalizeActiveRun,
|
|
216
221
|
readLiveSessionStatus,
|
|
222
|
+
activeRunCompletion: activeRunCompletionFromMessages,
|
|
217
223
|
sessionStatusType,
|
|
218
224
|
sessionIsIdle,
|
|
219
225
|
}
|