Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,29 +5,37 @@
## Git Workflow (铁律)

```
{type}_{name} ──merge──▶ dev ──push──▶ main
feat/* ──┐
fix/* ───┤
debug/* ─┼──merge──▶ dev ──[ TDD 覆盖率 + CI + E2E 全绿 ]──▶ dev 版本 ──push──▶ main
docs/* ──┤
refactor/*┤
test/* ──┤
chore/* ─┘
```

**各类 `{type}/*` 分支汇总到 `dev`。`dev` 是唯一的质量门禁**:只有 TDD 覆盖率 + CI + E2E 全部通过,才产出可发布的 `dev` 版本;通过后才能 push 到 `main`。

| Branch | CI/TDD | Purpose |
|--------|--------|---------|
| `{type}_{name}` | ❌ 不跑 | 功能/调试/重构等开发,频繁变更 |
| `dev` | ✅ **只有 dev 会触发 TDD + CI E2E 的 GitHub Actions** | 集成测试门禁,全绿才能推进 |
| `{type}/{name}` | ❌ 不跑 | 功能/修复/调试/文档/重构/测试/杂务等开发,频繁变更 |
| `dev` | ✅ **只有 dev 会触发 TDD 覆盖率 + CI + E2E 的 GitHub Actions** | 质量门禁,全绿才产出 dev 版本 |
| `main` | ❌ 不跑 | 发布专用,只接受 dev 验证通过的代码 |

**流程**:
1. 从 `dev` 切出 `{type}_{name}` 分支开发
1. 从 `dev` 切出 `{type}/{name}` 分支开发
2. 完成后合并到 `dev`
3. `dev` 必须全绿(TDD + CI E2E,由 GitHub Actions 配置触发)
3. `dev` 必须全绿(TDD 覆盖率 + CI + E2E,由 GitHub Actions 配置触发)→ 产出 dev 版本
4. 验证通过后才能 push 到 `main`
5. `main` 只用于发版(`fork-release` 手动触发)

CI 配置:`test.yml` 和 `typecheck.yml` 仅在 push 到 `dev` 时触发,`cancel-in-progress: false` 保证每次都跑完。

## Branch Names

Format: `{type}_{short-name}` where `type` is one of: `feat`, `debug`, `refactor`, `test`, `chore`. The short name uses hyphens, at most three words.
Format: `{type}/{short-name}` where `type` is one of: `feat`, `fix`, `debug`, `docs`, `refactor`, `test`, `chore`. The short name uses hyphens, at most three words.

Examples: `feat_session-recovery`, `debug_goal-loop`, `refactor_dag-spawn`, `chore_regenerate-sdk`.
Examples: `feat/session-recovery`, `fix/scroll-state`, `debug/goal-loop`, `docs/branch-naming`, `refactor/dag-spawn`, `test/auth-flow`, `chore/regenerate-sdk`.

## Commits and PR Titles

Expand Down
32 changes: 17 additions & 15 deletions packages/opencode/src/goal/goal.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -275,20 +275,12 @@ export const layer = Layer.effect(
const markDone = Effect.fn("Goal.markDone")(function* (sessionID: SessionID, reason: string) {
// User/tool-initiated completion: stop the running loop fiber, then
// perform terminal cleanup (publish done-updated → delete → publish cleared).
// State transitions are budget-neutral — turns_used counts continuation
// dispatches only (see spec: turn-budget-counts-continuation-dispatches-only),
// so markDone does NOT increment. deleteAndPublishDone loads the current
// row (preserving whatever turns_used a prior continue dispatch set) and
// re-renders the done snapshot from it.
yield* clearFiber(sessionID)
// Increment turns_used so this completion path reports the same
// "N turns" count as updateAfterJudge (which also += 1 before marking
// done). Without this, status lines and event payloads disagree on
// whether the terminal turn was consumed.
const current = yield* loadState(sessionID)
if (current) {
const incremented = new GoalState.Info({
...current,
turns_used: (current.turns_used + 1) as any,
last_turn_at: Date.now(),
})
yield* saveState(sessionID, incremented)
}
return yield* deleteAndPublishDone(sessionID, reason)
})

Expand DownExpand Up@@ -367,14 +359,24 @@ export const layer = Layer.effect(
const updated = new GoalState.Info({
...state,
status: "done",
turns_used: (state.turns_used + 1) as any,
// State transitions are budget-neutral — a `done` verdict drives no
// continuation dispatch, so it must NOT consume budget. turns_used
// reflects only continuation dispatches (see spec:
// turn-budget-counts-continuation-dispatches-only).
turns_used: state.turns_used,
last_turn_at: now,
last_verdict: "done",
last_reason: reason,
consecutive_parse_failures: newParseFailures as any,
})
yield* saveState(sessionID, updated)
yield* publishGoal(sessionID, updated)
// Do NOT publish goal.updated here. deleteAndPublishDone is the SOLE
// owner of the terminal event sequence (goal.updated(done) → delete →
// goal.cleared); publishing here would double-fire goal.updated(done)
// on every judge-declared completion (see spec:
// terminal-event-contract-publishes-exactly-once). We still saveState
// so deleteAndPublishDone can load the done row and re-render the
// snapshot. loop.ts invokes deleteAndPublishDone after this returns.
return {
state: updated,
shouldContinue: false,
Expand Down
14 changes: 11 additions & 3 deletions packages/opencode/src/goal/judge.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -56,11 +56,19 @@ export const run = Effect.fn("Goal.Judge.run")(function* (
timeout: GoalPrompts.DEFAULT_JUDGE_TIMEOUT,
}).pipe(
Effect.map((text) => parseJudgeResponse(text)),
// Fail-open on transport error
// Transport errors (timeout, network, non-JSON transport-level failure)
// count toward the pause budget (D5). Previously they returned
// parseFailed: false, which reset consecutive_parse_failures and let a
// flaky provider alternate bad-JSON and timeout indefinitely without
// ever hitting MAX_CONSECUTIVE_PARSE_FAILURES. Returning parseFailed: true
// feeds them through the same auto-pause path as parse failures, treating
// "judge is unreliable" uniformly regardless of failure mode. The verdict
// stays "continue" so a single transient blip does not stall the loop;
// it only pauses after MAX_CONSECUTIVE_PARSE_FAILURES in a row.
Effect.orElseSucceed((): JudgeResult => ({
verdict: "continue",
reason: "judge transport error",
parseFailed: false,
reason: "judge transport error (timeout or network) — counting toward pause budget",
parseFailed: true,
})),
)
})
90 changes: 78 additions & 12 deletions packages/opencode/src/goal/loop.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,33 @@ export function shouldPreempt(
return lastUserAt > lastAsstAt
}

/**
* Pure predicate for the zombie-goal freshness guard (D6). Returns true when a
* goal is "orphaned": active, has run zero continuations (turns_used === 0),
* was created more than FRESHNESS_THRESHOLD ago, and the initial kick never
* produced an assistant message (provider error, model refusal, empty response).
*
* Used by GoalLoop.afterIdle to convert the silent orphan state into a visible,
* recoverable pause. Without it, every subsequent afterIdle would abort at the
* `if (!lastAssistant) return` line and the goal would sit permanently "active"
* with no progress.
*
* `now` defaults to Date.now() for production; tests pass an explicit value for
* determinism.
*/
export function isStaleZombie(
state: { status: string; turns_used: number; created_at: number },
hasAssistant: boolean,
now: number = Date.now(),
): boolean {
return (
state.status === "active" &&
Number(state.turns_used) === 0 &&
!hasAssistant &&
now - state.created_at > GoalPrompts.FRESHNESS_THRESHOLD
)
}

export const layer = Layer.effect(
Service,
Effect.gen(function* () {
Expand DownExpand Up@@ -81,6 +108,37 @@ export const layer = Layer.effect(
const goalState = yield* goal.load(sessionID)
if (!goalState || goalState.status !== "active") return

// Zombie-goal freshness guard (D6). If the goal is active but has run
// zero continuations and is older than FRESHNESS_THRESHOLD, the initial
// kick may have failed silently (provider error, model refusal, empty
// response). Without this guard every subsequent afterIdle aborts at the
// `if (!lastAssistant) return` line below, leaving the goal permanently
// "active" with no progress — a silent orphan. Convert that into a
// visible, recoverable pause so the user can /goal resume.
//
// The probe loads only 1 message (not the full 20) so we don't pay for
// the whole message window just to discover staleness; the stale path
// returns early so the limit:20 load below never runs when the guard
// fires. Uses pauseAndPublish (fiber-safe) — NOT goal.pause — because
// we ARE the loop fiber tracked in the fibers map (same self-interrupt
// hazard discipline as the done / shouldPreempt branches below).
if (
Number(goalState.turns_used) === 0 &&
Date.now() - goalState.created_at > GoalPrompts.FRESHNESS_THRESHOLD
) {
const probeMsgs = yield* sessions.messages({ sessionID, limit: 1 })
const hasAssistant = probeMsgs.some((m) => m.info.role === "assistant")
if (isStaleZombie(goalState, hasAssistant)) {
yield* goal
.pauseAndPublish(
sessionID,
`initial kick produced no assistant response within ${GoalPrompts.FRESHNESS_THRESHOLD / 1000}s — likely provider error or model refusal. Use /goal resume to retry.`,
)
.pipe(Effect.ignore)
return
}
}

const msgs = yield* sessions.messages({ sessionID, limit: 20 })
const lastAssistant = [...msgs].reverse().find((m) => m.info.role === "assistant")
if (!lastAssistant) return
Expand DownExpand Up@@ -185,22 +243,30 @@ export const layer = Layer.effect(

const reloadedState = yield* goal.load(sessionID)
if (!reloadedState || reloadedState.status !== "active") return
const continuationText = GoalPrompts.renderContinuation(reloadedState.goal, reloadedState.subgoals ?? [])

// Surface the per-turn progress indicator visibly (e.g.
// "↻ 继续推进目标(2/10):…"). updateAfterJudge computed this message;
// emit it as a noReply non-synthetic part so it renders in the transcript
// without spawning another agent turn. The continuation prompt below
// (ignored) is what actually drives the next loop iteration.
yield* promptSvc.prompt({
sessionID,
noReply: true,
parts: [{ type: "text", text: updateResult.message }],
}).pipe(Effect.ignore)
// Single merged continuation injection (D4.2). This replaces the former
// two-call sequence (a `noReply` progress line + an `ignored:true`
// continuation). The merged prompt carries goal text, subgoals, the
// turns/budget line, and the last judge reason, plus the autonomous-mode
// frame — and it is BOTH the user-visible per-turn progress line AND the
// prompt that drives the next agent turn.
//
// It is deliberately a plain text part: no `noReply` (so it spawns the
// next agent turn) and no `ignored` (so it renders in the transcript AND
// reaches the model — `ignored:true` text parts are filtered out of model
// messages in MessageV2.toModelMessagesEffect). Driving + visibility +
// model-reachability are all required by D4.2.
const continuationText = GoalPrompts.renderContinuation({
goal: reloadedState.goal,
subgoals: reloadedState.subgoals ?? [],
turnsUsed: Number(reloadedState.turns_used),
maxTurns: Number(reloadedState.max_turns),
lastJudgeReason: reloadedState.last_reason,
})

yield* promptSvc.prompt({
sessionID,
parts: [{ type: "text", text: continuationText, ignored: true }],
parts: [{ type: "text", text: continuationText }],
})

// NOTE: We deliberately DO NOT call goal.clearLoopFiber here. The
Expand Down
102 changes: 75 additions & 27 deletions packages/opencode/src/goal/prompts.ts
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,16 @@
import { GoalState } from "./state"

export * as GoalPrompts from "./prompts"

export const DEFAULT_MAX_TURNS = 20
export const DEFAULT_JUDGE_TIMEOUT = 30_000
export const MAX_CONSECUTIVE_PARSE_FAILURES = 3
export const JUDGE_RESPONSE_SNIPPET_CHARS = 4000

export const CONTINUATION_PROMPT_TEMPLATE = `[Continuing toward your standing goal]
Goal: {goal}

You are in autonomous mode — interactive questions are disabled and will not receive answers. Do not ask the user for clarification or confirmation. Make all decisions independently based on your best judgment.

Continue working toward this goal. Take the next concrete step.
If you believe the goal is complete, state so explicitly and stop.
If you are completely blocked and cannot make any progress, state the blocker explicitly and stop.`

export const CONTINUATION_PROMPT_WITH_SUBGOALS_TEMPLATE = `[Continuing toward your standing goal]
Goal: {goal}

Additional criteria the user added mid-loop:
{subgoals}

You are in autonomous mode — interactive questions are disabled and will not receive answers. Do not ask the user for clarification or confirmation. Make all decisions independently based on your best judgment.

Continue working toward this goal. Take the next concrete step.
If you believe the goal is complete, state so explicitly and stop.
If you are completely blocked and cannot make any progress, state the blocker explicitly and stop.`
// Zombie-goal freshness guard threshold (D6). A goal that is still active with
// turns_used 0 after this many ms, and whose initial kick produced no assistant
// message, is treated as orphaned and auto-paused so the user can recover via
// /goal resume instead of the goal sitting silently "active" forever.
export const FRESHNESS_THRESHOLD = 120_000

export const JUDGE_SYSTEM_PROMPT = `You are an autonomous-goal completion judge.
You will receive:
Expand DownExpand Up@@ -64,12 +50,74 @@ Agent's most recent response (last {snippetChars} chars):

Is the goal done? For each sub-goal, provide concrete evidence it was met. Do not accept vague claims like "all requirements met".`

export function renderContinuation(goal: string, subgoals: ReadonlyArray<string>): string {
if (subgoals.length === 0)
return CONTINUATION_PROMPT_TEMPLATE.replace("{goal}", goal)
return CONTINUATION_PROMPT_WITH_SUBGOALS_TEMPLATE
.replace("{goal}", goal)
.replace("{subgoals}", subgoals.map((s, i) => `${i + 1}. ${s}`).join("\n"))
export interface ContinuationInput {
readonly goal: string
readonly subgoals: ReadonlyArray<string>
readonly turnsUsed: number
readonly maxTurns: number
readonly lastJudgeReason?: string
}

// Renders the single merged continuation injection (D4.2). Carries goal text,
// subgoals, turns/budget, the last judge reason (labeled), and the autonomous-mode
// frame. This is both the user-visible per-turn progress line AND the prompt that
// drives the next agent turn — it must reach the model (no `ignored` flag at the
// call site) and render in the transcript (no `noReply`).
export function renderContinuation(input: ContinuationInput): string {
const remaining = Math.max(0, input.maxTurns - input.turnsUsed)
const lines = [
"[Continuing toward your standing goal]",
`Goal: ${input.goal}`,
`Turns: ${input.turnsUsed}/${input.maxTurns} (${remaining} remaining)`,
]
if (input.subgoals.length > 0) {
lines.push("Subgoals:")
lines.push(...input.subgoals.map((s, i) => `${i + 1}. ${s}`))
}
if (input.lastJudgeReason) lines.push(`Judge feedback: ${input.lastJudgeReason}`)
lines.push("")
lines.push(
"You are in autonomous mode — interactive questions are disabled and will not receive answers. Do not ask the user for clarification or confirmation. Make all decisions independently based on your best judgment.",
)
lines.push("")
lines.push("Continue working toward this goal. Take the next concrete step.")
lines.push("If you believe the goal is complete, state so explicitly and stop.")
lines.push(
"If you are completely blocked and cannot make any progress, state the blocker explicitly and stop.",
)
return lines.join("\n")
}

// Renders the dynamic system-prompt fragment for an active/paused goal (D4.1).
// Pure: injected into the system prompt by SystemPrompt.goal(sessionID).
export function renderGoalSystemBlock(state: GoalState.Info): string {
const turnsUsed = Number(state.turns_used)
const maxTurns = Number(state.max_turns)
const remaining = Math.max(0, maxTurns - turnsUsed)
const subgoals = state.subgoals ?? []
const lines = [
"## Current Goal (autonomous loop)",
`Goal: ${state.goal}`,
`Status: ${state.status}`,
`Turns: ${turnsUsed}/${maxTurns} (${remaining} remaining)`,
]
if (subgoals.length > 0) {
lines.push("Subgoals:")
lines.push(...subgoals.map((s, i) => ` ${i + 1}. ${s}`))
} else {
lines.push("Subgoals: none")
}
if (state.status === "paused" && state.paused_reason) {
lines.push(`Paused because: ${state.paused_reason}`)
}
if (state.last_verdict) {
lines.push(
state.last_reason
? `Last judge verdict: ${state.last_verdict} — ${state.last_reason}`
: `Last judge verdict: ${state.last_verdict}`,
)
}
return lines.join("\n")
}

export function renderJudgeUserPrompt(
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/session/prompt.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1462,7 +1462,7 @@ export const layer = Layer.effect(
instruction.system().pipe(Effect.orDie),
sys.mcp(agent, session.permission),
sys.hooks(),
sys.goal(),
sys.goal(sessionID),
MessageV2.toModelMessagesEffect(msgs, model),
])
const system = [
Expand Down
11 changes: 4 additions & 7 deletions packages/opencode/src/session/prompt/goal.txt
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,9 @@

OpenCode has a built-in **autonomous goal** feature. Use `/goal <description>` to set a persistent goal that the agent will work toward autonomously across multiple turns.

A `goal` **tool** is available during any turn. Use it to query the current goal and, when the goal is genuinely done, self-declare completion so the goal loop exits immediately instead of waiting for an external judge.
While a goal is active or paused, a **live "Current Goal" block** is injected into the system prompt at the start of every turn — it carries the goal text, status, turns used/remaining, subgoals, and the last judge verdict. You do not need to call a tool to learn this state; read it from the system prompt.

A `goal` **tool** is also available. Use `goal(action: "complete")` to self-declare completion when the goal is genuinely done, so the loop exits immediately instead of waiting for the external judge. `goal(action: "status")` is an optional check-in (see below).

## Commands (user-facing; only you or the user can issue these)

Expand All@@ -19,14 +21,9 @@ A `goal` **tool** is available during any turn. Use it to query the current goal

## Tool (agent-facing; call this during your turn)

- `goal(action: "status")` — Current goal text, status, turns used/remaining, subgoals, and pause reason. Returns clear information when no goal is active. Call proactively before claiming progress, so you know whether a goal loop is running and how much budget remains.
- `goal(action: "status")` — Current goal text, status, turns used/remaining, subgoals, and pause reason. This is an OPTIONAL check-in: the same live state is already in your system prompt each turn, so you do not need to call `status` to know whether a goal loop is running or how much budget remains. Use it only for a deliberate mid-turn re-check (e.g., after a long operation that may have changed state) or to inspect `pausedReason`.
- `goal(action: "complete", reason: "...")` — Declare the goal achieved. **This bypasses the external judge and ends the loop immediately.** Pass a one-sentence summary of what was delivered (e.g., "3 tests written and passing; refactor verified."). The goal is then auto-cleared.

### When to call `goal(status)`

- Once per non-trivial turn, to know whether you are in a goal loop and what turns of budget are left.
- When subgoals exist, to check which ones remain before finishing.

### When to call `goal(complete)`

- When the goal produced verifiable deliverables (files written, tests passing, a diagnosis, a concrete answer) and no subgoals remain.
Expand Down
Loading