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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ odek run "@README.md what does this project do?"
| [Extensions](docs/EXTENSIONS.md) | `odek-extension/v1` contract: MCP limits, artifact refs, event stream, external refs, budgets |
| [Maintenance](docs/MAINTENANCE.md) | Storage janitor: retention, log rotation, `odek cleanup` |
| [Extended Memory](docs/EXTENDED_MEMORY.md) | Atomic long-term memory layer (opt-in) |
| [Planning](docs/PLANNING.md) | Incremental plan revisions, acceptance checks, completion evidence |
| [Planning](docs/PLANNING.md) | Incremental plan revisions, acceptance checks, completion evidence, bounded reassessment hints |
| [Runtime Evals](docs/EVALS.md) | Deterministic task scenarios, independent checks, JSON reports |
| [Tool Selection](docs/TOOL_SELECTION.md) | Tool whitelist/blacklist guide and names reference |
| [Daily Worker](docs/DAILY-WORKER.md) | Headless scheduled-worker patterns |
Expand Down
2 changes: 1 addition & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ type Config struct {
// stream (schema odek.event/v1): run_started, iteration_completed,
// tool_call_started/completed/failed, session_saved,
// context_trimmed, budget_exceeded, plan_created, plan_updated,
// plan_blocked, subagent_denied, subagent_spawned,
// plan_blocked, plan_reassessment, subagent_denied, subagent_spawned,
// subagent_completed, subagent_concurrency_wait,
// run_completed, run_failed.
// Dispatch is non-blocking (buffered, drop-on-full) and
Expand Down
2 changes: 1 addition & 1 deletion docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ Zero external test dependencies — tests use `httptest`, `testing`, and the sta
### Runtime evaluations

Run `make eval` (or `go run ./cmd/odek-eval`) from the repository root. The
eleven scripted scenarios use the production loop with localhost fixture tools
fourteen scripted scenarios use the production loop with localhost fixture tools
and independent outcome checks. The command writes a JSON report and exits
nonzero if a scenario assertion fails; expected task failures can still pass
the scenario. No model credentials or external provider calls are needed.
Expand Down
7 changes: 4 additions & 3 deletions docs/EVALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ prices or estimate cost.
The initial suite covers verified artifact work, a failed read followed by a
false success claim, unrelated reads after a write, transient failure and
recovery, cancellation, and plan acceptance checks for success, failed
evidence, missing evidence, and incremental revision behavior. Negative cases can still be scenario passes
evidence, missing evidence, incremental revision behavior, and bounded plan
reassessment signals. Negative cases can still be scenario passes
when the oracle correctly records that the task did not succeed. The plan
cases use the production `plan` tool and `PlanStore`, including the runtime
incomplete marker for failed or missing evidence.
Expand All @@ -39,9 +40,9 @@ go run ./cmd/odek-eval > eval-report.json
```

Exit status is 0 when every scenario passes, 1 for scenario failures, and 2
if the report cannot be encoded. The eleven-case baseline includes a deliberate
if the report cannot be encoded. The fourteen-case baseline includes a deliberate
unguarded false-success control: all scenarios pass while
`false_completion_rate` is 1/11 (about 0.091). That expected control is not a failure of
`false_completion_rate` is 1/14 (about 0.071). That expected control is not a failure of
the checked-plan guard or a live-model benchmark.

Add a case to `internal/eval.Scenarios` with fresh fixture state, scripted
Expand Down
8 changes: 7 additions & 1 deletion docs/EXTENSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ odek can emit a structured runtime event stream: **one JSON object per line
- `type` is one of: `run_started`, `iteration_completed`,
`tool_call_started`, `tool_call_completed`, `tool_call_failed`,
`session_saved`, `context_trimmed`, `budget_exceeded`, `run_completed`,
`run_failed`, `plan_created`, `plan_updated`, `plan_blocked`, `subagent_denied`,
`run_failed`, `plan_created`, `plan_updated`, `plan_blocked`, `plan_reassessment`, `subagent_denied`,
`subagent_spawned`, `subagent_completed`, `subagent_concurrency_wait`,
`side_call_usage`.
- `run_id` is a random 128-bit hex identifier generated per agent run and
Expand Down Expand Up @@ -198,6 +198,7 @@ Per-type `data` fields:
| `plan_created` | `steps` (total count), `version` |
| `plan_updated` | `steps`, `done`, `in_progress`, `blocked`, `pending`, `version` |
| `plan_blocked` | `steps`, `blocked`, `version` |
| `plan_reassessment` | `reason`, `failure_batches` (always `3`) |
| `subagent_denied` | `task_index`, `class`, `reason` (emitted by `delegate_tasks` for each policy denial a child reports) |
| `subagent_spawned` | `task_id`, `pid`, `depth`, `timeout_seconds`, `goal_sha256` (16 hex chars: first 8 bytes of SHA-256 of the goal; the goal itself is never logged) |
| `subagent_completed` | `task_id`, `status`, plus optional `iterations`, `duration_seconds`, `tokens_used`, `artifact_count` when the child result carried them |
Expand Down Expand Up @@ -235,6 +236,11 @@ emits `plan_updated` with unchanged counts (the version stream stays
gapless). Plan events carry no `iteration`: mutations fire inside parallel
tool goroutines, so consumers correlate via the surrounding
`tool_call_started`/`tool_call_completed` pair for the `plan` tool.
`plan_reassessment` is emitted when an active open plan reaches one of the
initial bounded failure triggers; its data contains only `reason` and
`failure_batches: 3`. The corresponding hint is delivered on the next normal
model request, with no side model call, tool execution, or automatic plan
mutation.

Sink behavior (`--events-jsonl`): the file is created (and hardened) with
`0600` permissions, the parent directory must already exist, a symlink at the
Expand Down
51 changes: 48 additions & 3 deletions docs/PLANNING.md
Original file line number Diff line number Diff line change
Expand Up @@ -659,8 +659,9 @@ stream (`Config.EventHandler`, `odek run --events-jsonl`, `/api/events`):
| `plan_created` | `create` — including wholesale replace over an existing plan | `steps`, `version` |
| `plan_updated` | every other version-bumping mutation (`update`, `complete`) | `steps`, `done`, `in_progress`, `blocked`, `pending`, `version` |
| `plan_blocked` | three consecutive `blocked` status transitions | `steps`, `blocked`, `version` |
| `plan_reassessment` | the active open plan meets a reassessment trigger | `reason`, `failure_batches` (always `3`) |

Emission: `PlanStore.SetOnChange` wires the engine's emitter at
For plan mutation events, `PlanStore.SetOnChange` wires the engine's emitter at
`SetPlanStore` time; the store fires exactly once per effective mutation
under its mutex, so event order always matches version order even inside
parallel tool batches. Idempotent no-ops, the read-only `get` verb, and
Expand All @@ -669,10 +670,46 @@ counts and the version ONLY — never step titles or notes (the same
minimality invariant as the args-digest rule on tool-call events). A
note-only update bumps the version and therefore emits `plan_updated` with
unchanged counts — deliberate, so the version stream stays gapless for
consumers correlating versions. There is no `iteration` field: mutations
consumers correlating versions. These events have no `iteration` field: mutations
fire inside parallel tool goroutines with no iteration context; consumers
correlate via the surrounding `tool_call_started`/`tool_call_completed`
pair for the `plan` tool.
pair for the `plan` tool. `plan_reassessment` is emitted separately after
a completed tool batch and includes its iteration number.

### Failure-driven reassessment

With an active open plan, the engine emits a bounded reassessment hint when
either of the initial triggers is met: the same acceptance check fails in
three separate tool batches, or three consecutive observation batches fail
with the same trusted runtime error class across at least two distinct
hashed tool-and-argument fingerprints. Tool success resets the corresponding
failure streak; a successful run of a check resets that check's failures.
Batch-level approval denials, typed cancellations, budget-skipped calls,
and background-polling outcomes do not count. Tool-internal generic errors
remain ordinary failures when they cannot be distinguished from those outcomes.

The hint appears on the next normal model request. It recommends changing the
approach, splitting the work, or delegating a bounded investigation and
reporting a blocker while preserving checks, approvals, and budgets. It never
retries denied actions, bypasses approval, exceeds budgets, executes tools, or
changes the plan automatically. A three-completed-batch cooldown permits at
most two hints per run, and reassessment state resets at each turn. There is
no generic inactivity trigger, full-budget optimizer, or cost-planning logic
in this feature, and no side model call.

The structured `odek.event/v1` event carries `reason` and
`failure_batches: 3`; the corresponding `plan_reassessment` signal carries
the reason code in `Detail` and the threshold in `Count`. Reason codes are
`repeated_check_failure` and `varied_tool_failures`. Events carry no tool
arguments, output, paths, or plan text.

Only batches containing eligible observations advance the varied-failure
streak; plan-only updates do not reset it. A mixed success/failure batch
resets that streak. Repeated-check tracking retains at most 64 check
fingerprints, with FIFO eviction; varied-failure tracking retains at most
three error classes and eight fingerprints per class. These signals indicate
repeated operational failures, not proof that unrelated commands share one
underlying cause.

---

Expand Down Expand Up @@ -874,6 +911,14 @@ semantics, payload minimality, `ExtractPlan`), `cmd/odek/serve_plan_test.go`
a decompose-with-`revise` hint and emit `plan_blocked` (`steps`, `blocked`,
`version` only). The streak resets on `create` or a `done` / `in_progress`
transition, and after firing (once then reset).
- **Failure-driven reassessment.** An active open plan can request a
bounded approach change after three separate failures of one check, or
three consecutive observation batches sharing a trusted runtime error class
across at least two distinct tool/argument fingerprints. The next normal
model request receives at most two such hints per run with a three-batch
cooldown; batch denials, typed cancellations, budget-skipped calls, and
background-polling outcomes are excluded. No tool, side model call, or automatic plan change
is triggered.
- **Remaining-steps on exhaustion.** Pending / in_progress / blocked IDs and
statuses are appended as wrapped derived context (`plan_remaining`, ingest
recorded) on iteration-cap, `budgetExceeded` (even when the summary side
Expand Down
84 changes: 71 additions & 13 deletions internal/eval/eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
Expand All @@ -24,11 +25,13 @@ type ToolCall struct {
Error bool
}
type Fixture struct {
Values map[string]string
Calls []ToolCall
GoodCalls []ToolCall
Plan *loop.PlanState
mu sync.Mutex
Values map[string]string
Calls []ToolCall
GoodCalls []ToolCall
Plan *loop.PlanState
Hints []string
HintRequests []int
mu sync.Mutex
}

func (f *Fixture) get(k string) string { f.mu.Lock(); defer f.mu.Unlock(); return f.Values[k] }
Expand All @@ -42,14 +45,15 @@ type OracleResult struct {
}

type Scenario struct {
Name string
Task string
Responses []string
Tools []tool.Tool
Fixture *Fixture
Oracle func(*Fixture, string, error, []ToolCall) OracleResult
Cancel bool
Plan bool
Name string
Task string
Responses []string
Tools []tool.Tool
Fixture *Fixture
Oracle func(*Fixture, string, error, []ToolCall) OracleResult
Cancel bool
Plan bool
RequestInspector func([]byte)
}
type ToolCallReport struct {
Name string `json:"name"`
Expand Down Expand Up @@ -137,6 +141,11 @@ func runCase(parent context.Context, s Scenario, opts RunOptions) CaseReport {
calls := []ToolCall{}
n := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.RequestInspector != nil {
if body, readErr := io.ReadAll(io.LimitReader(r.Body, 2<<20)); readErr == nil {
s.RequestInspector(body)
}
}
mu.Lock()
i := n
n++
Expand Down Expand Up @@ -277,6 +286,8 @@ func (t fixtureTool) Call(raw string) (string, error) {
return v, nil
}
return "missing evidence", nil
case "always_fail":
return "fixture failure", fmt.Errorf("fixture failure")
default:
return "", fmt.Errorf("unknown fixture")
}
Expand Down Expand Up @@ -327,6 +338,9 @@ func Scenarios() []Scenario {
f9 := baseFixture()
f10 := baseFixture()
f11 := baseFixture()
f12 := baseFixture()
f13 := baseFixture()
f14 := baseFixture()
return []Scenario{
{Name: "successful_fix_verified", Task: "write and verify artifact", Fixture: f1, Tools: []tool.Tool{Tool(f1, "write_file", "write"), Tool(f1, "read_file", "read")}, Responses: []string{toolCall("write_file", "w1", `{"key":"artifact","value":"fixed"}`), toolCall("read_file", "r1", `{"key":"artifact"}`), final("verified")}, Oracle: func(f *Fixture, r string, e error, _ []ToolCall) OracleResult {
if e != nil {
Expand Down Expand Up @@ -409,6 +423,24 @@ func Scenarios() []Scenario {
}
return OracleResult{TaskSuccess: true}
}},
{Name: "reassessment_repeated_check_failure", Task: "recover after repeated acceptance check failures", Fixture: f12, Plan: true, RequestInspector: captureHints(f12), Tools: []tool.Tool{Tool(f12, "read_file", "read"), Tool(f12, "write_file", "write")}, Responses: []string{toolCall("plan", "p1", `{"verb":"create","steps":[{"id":"fix","title":"Fix","checks":[{"id":"e1","description":"Read evidence","tool":"read_file","arguments":{"key":"evidence"}}]}]}`), toolCall("read_file", "r1", `{"key":"evidence"}`), toolCall("read_file", "r2", `{"key":"evidence"}`), toolCall("read_file", "r3", `{"key":"evidence"}`), toolCall("plan", "p2", `{"verb":"revise","reason":"repair after repeated check failures","operations":[{"kind":"edit","step_id":"fix","note":"repair"}]}`), toolCall("write_file", "w1", `{"key":"evidence","value":"fixed"}`), toolCall("read_file", "r4", `{"key":"evidence"}`), toolCall("plan", "p3", `{"verb":"update","updates":[{"id":"fix","status":"done"}]}`), final("complete")}, Oracle: func(f *Fixture, r string, e error, c []ToolCall) OracleResult {
if e != nil || len(f.Hints) == 0 || len(f.HintRequests) == 0 || f.HintRequests[0] != 5 || !strings.HasPrefix(f.Hints[0], "[odek plan reassessment: repeated_check_failure]") || f.Plan == nil || len(f.Plan.Steps) != 1 || f.Plan.Steps[0].Status != loop.StepDone || f.get("evidence") != "fixed" || len(c) != 8 || !c[1].Error || !c[2].Error || !c[3].Error || c[4].Error || c[5].Error || c[6].Error || c[7].Error {
return OracleResult{Errors: []string{"repeated-check reassessment did not inject a hint at the threshold and complete repair"}}
}
return OracleResult{TaskSuccess: true}
}},
{Name: "reassessment_varied_tool_failures", Task: "recover after varied tool failures", Fixture: f13, Plan: true, RequestInspector: captureHints(f13), Tools: []tool.Tool{Tool(f13, "fail_a", "always_fail"), Tool(f13, "fail_b", "always_fail"), Tool(f13, "fail_c", "always_fail"), Tool(f13, "read_file", "read"), Tool(f13, "write_file", "write")}, Responses: []string{toolCall("plan", "p1", `{"verb":"create","steps":[{"id":"fix","title":"Fix","checks":[{"id":"e1","description":"Read evidence","tool":"read_file","arguments":{"key":"evidence"}}]}]}`), toolCall("fail_a", "a1", `{}`), toolCall("fail_b", "b1", `{}`), toolCall("fail_c", "c1", `{}`), toolCall("write_file", "w1", `{"key":"evidence","value":"fixed"}`), toolCall("read_file", "r1", `{"key":"evidence"}`), toolCall("plan", "p2", `{"verb":"update","updates":[{"id":"fix","status":"done"}]}`), final("complete")}, Oracle: func(f *Fixture, r string, e error, c []ToolCall) OracleResult {
if e != nil || len(f.Hints) == 0 || len(f.HintRequests) == 0 || f.HintRequests[0] != 5 || !strings.HasPrefix(f.Hints[0], "[odek plan reassessment: varied_tool_failures]") || f.Plan == nil || len(f.Plan.Steps) != 1 || f.Plan.Steps[0].Status != loop.StepDone || f.get("evidence") != "fixed" || len(c) != 7 || !c[1].Error || !c[2].Error || !c[3].Error || c[4].Error || c[5].Error || c[6].Error {
return OracleResult{Errors: []string{"varied-failure reassessment did not inject a hint at the threshold and complete repair"}}
}
return OracleResult{TaskSuccess: true}
}},
{Name: "reassessment_transient_failure_no_hint", Task: "recover from one transient failure", Fixture: f14, Plan: true, RequestInspector: captureHints(f14), Tools: []tool.Tool{Tool(f14, "flaky", "flaky")}, Responses: []string{toolCall("plan", "p1", `{"verb":"create","steps":[{"id":"fix","title":"Fix"}]}`), toolCall("flaky", "f1", `{}`), toolCall("flaky", "f2", `{}`), toolCall("plan", "p2", `{"verb":"update","updates":[{"id":"fix","status":"done"}]}`), final("complete")}, Oracle: func(f *Fixture, r string, e error, c []ToolCall) OracleResult {
if e != nil || len(f.Hints) != 0 || f.Plan == nil || len(f.Plan.Steps) != 1 || f.Plan.Steps[0].Status != loop.StepDone || f.get("attempts") != "2" || len(c) != 4 || !c[1].Error || c[2].Error || c[3].Error {
return OracleResult{Errors: []string{"transient recovery incorrectly triggered reassessment or failed completion"}}
}
return OracleResult{TaskSuccess: true}
}},
}
}

Expand All @@ -422,3 +454,29 @@ func hasGoodCall(f *Fixture, name string) bool {
}
return false
}

func captureHints(f *Fixture) func([]byte) {
request := 0
return func(body []byte) {
f.mu.Lock()
defer f.mu.Unlock()
request++
var req struct {
Messages []struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"messages"`
}
if json.Unmarshal(body, &req) != nil {
return
}
for _, m := range req.Messages {
if m.Role == "system" {
if idx := strings.Index(m.Content, "[odek plan reassessment:"); idx >= 0 {
f.Hints = append(f.Hints, m.Content[idx:])
f.HintRequests = append(f.HintRequests, request)
}
}
}
}
}
8 changes: 4 additions & 4 deletions internal/eval/eval_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import (

func TestDefaultScenariosAreIndependentAndBounded(t *testing.T) {
r := Run(context.Background(), Scenarios())
if r.Total != 11 {
t.Fatalf("total=%d want 11", r.Total)
if r.Total != 14 {
t.Fatalf("total=%d want 14", r.Total)
}
if r.Failed != 0 {
t.Fatalf("scenario failures=%d report=%+v", r.Failed, r)
Expand All @@ -28,8 +28,8 @@ func TestDefaultScenariosAreIndependentAndBounded(t *testing.T) {
taskSuccess++
}
}
if taskSuccess != 4 {
t.Fatalf("task successes=%d want 4", taskSuccess)
if taskSuccess != 7 {
t.Fatalf("task successes=%d want 7", taskSuccess)
}
for _, c := range r.Cases {
if c.Name == "plan_check_failed" || c.Name == "plan_check_missing" {
Expand Down
1 change: 1 addition & 0 deletions internal/events/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const (
TypePlanCreated = "plan_created"
TypePlanUpdated = "plan_updated"
TypePlanBlocked = "plan_blocked"
TypePlanReassessment = "plan_reassessment"
TypeSubagentSpawned = "subagent_spawned"
TypeSubagentCompleted = "subagent_completed"
TypeSideCallUsage = "side_call_usage"
Expand Down
Loading
Loading