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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,6 @@ SUB_AGENTS_IMPROVEMENTS.md
# opencode agent local memory
.kai/
.gotmp/

# Sub-agent artifact staging (delegate_tasks result channel)
.odek-artifacts/
18 changes: 12 additions & 6 deletions cmd/odek/artifact_read_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,19 +54,25 @@ func TestArtifactRegistry_RegisterLookupEvict(t *testing.T) {
}
}

func TestArtifactRegistry_DuplicateIDLastWins(t *testing.T) {
func TestArtifactRegistry_DuplicateIDFirstWins(t *testing.T) {
resetArtifactRegistryForTest()
r1, p1 := regRef(t, "dup", "first")
registerSubagentArtifact(artifactEntry{Ref: r1, Path: p1, TaskIdx: 0})
r2, p2 := regRef(t, "dup", "second")
dup := registerSubagentArtifact(artifactEntry{Ref: r2, Path: p2, TaskIdx: 1})
alias, dup := registerSubagentArtifact(artifactEntry{Ref: r2, Path: p2, TaskIdx: 1})

if !dup {
t.Error("duplicate registration must be reported")
}
got, _ := lookupSubagentArtifact("dup")
if got.Path != p2 || got.TaskIdx != 1 {
t.Errorf("last-wins broken: %+v", got)
if got.Path != p1 || got.TaskIdx != 0 {
t.Errorf("first-wins broken: %+v", got)
}
if alias != "dup.t2" {
t.Errorf("alias = %q, want dup.t2", alias)
}
if got, ok := lookupSubagentArtifact(alias); !ok || got.Path != p2 || got.TaskIdx != 1 {
t.Errorf("alias lookup broken: (%+v, %v)", got, ok)
}
}

Expand Down Expand Up @@ -184,10 +190,10 @@ func TestRegisterTaskArtifacts_DuplicateNote(t *testing.T) {
raw2 := fmt.Sprintf(`{"status":"success","summary":"ok","artifacts":[{"schema":%q,"id":"dup","uri":"file://%s","media_type":"text/markdown","sha256":%q,"size_bytes":%d}]}`,
artifact.SchemaArtifactRef, p2, expectedSHA(t, c2), size2)

if notes := registerTaskArtifacts(raw1, dir, 0); len(notes) != 0 {
if notes := registerTaskArtifacts(raw1, dir, 0, "task-a"); len(notes) != 0 {
t.Errorf("first registration must not note: %v", notes)
}
notes := registerTaskArtifacts(raw2, dir, 1)
notes := registerTaskArtifacts(raw2, dir, 1, "task-b")
if len(notes) != 1 || !strings.Contains(notes[0], "duplicate") {
t.Errorf("duplicate must produce a note: %v", notes)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/odek/artifact_read_toctou_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func registerArtifactForTOCTOU(t *testing.T, id, content string) (root, path str
}
raw := fmt.Sprintf(`{"status":"success","summary":"ok","artifacts":[{"schema":%q,"id":%q,"uri":"file://%s","media_type":"text/markdown","sha256":%q,"size_bytes":%d}]}`,
artifact.SchemaArtifactRef, id, path, expectedSHA(t, content), len(content))
if notes := registerTaskArtifacts(raw, root, 0); len(notes) != 0 {
if notes := registerTaskArtifacts(raw, root, 0, "task-a"); len(notes) != 0 {
t.Fatalf("clean registration must not produce notes: %v", notes)
}
// The registration helper silently skips validation failures — make
Expand Down
4 changes: 2 additions & 2 deletions cmd/odek/artifact_read_tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,8 +269,8 @@ func (t *artifactReadTool) Call(args string) (string, error) {
}

var b strings.Builder
fmt.Fprintf(&b, "artifact %s (%s, %d bytes, sha256:%s) — bytes %d..%d of %d",
entry.Ref.ID, entry.Ref.MediaType, size, shaPrefix, in.Offset, in.Offset+int64(len(data)), total)
fmt.Fprintf(&b, "artifact %s (%s, %d bytes, sha256:%s) — task %d — bytes %d..%d of %d",
entry.Ref.ID, entry.Ref.MediaType, size, shaPrefix, entry.TaskIdx+1, in.Offset, in.Offset+int64(len(data)), total)
if truncated {
b.WriteString(" — TRUNCATED, call again with offset to continue")
}
Expand Down
22 changes: 12 additions & 10 deletions cmd/odek/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2332,16 +2332,17 @@ func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager, approver d
tools := []odek.Tool{
shell,
&delegateTasksTool{
maxConcurrency: subConcurrency,
sharedSem: sharedChildSem(subConcurrency),
odekPath: os.Args[0],
apiKey: apiKey,
timeout: time.Duration(subTimeout) * time.Second,
maxDepth: subDepth,
budgetInherit: subInherit,
selfTrust: selfTrust,
profiles: tcfg.Profiles,
artifactsRoot: artifactsRoot, // empty ⇒ no artifact dirs created
maxConcurrency: subConcurrency,
sharedSem: sharedChildSem(subConcurrency),
odekPath: os.Args[0],
apiKey: apiKey,
timeout: time.Duration(subTimeout) * time.Second,
maxDepth: subDepth,
budgetInherit: subInherit,
selfTrust: selfTrust,
profiles: tcfg.Profiles,
artifactsRoot: artifactsRoot, // empty ⇒ no artifact dirs created
artifactReadAvailable: artifactReadEnabled(tcfg),
},
&listSubagentProfilesTool{
profiles: tcfg.Profiles,
Expand Down Expand Up @@ -2421,6 +2422,7 @@ func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager, approver d

return tools
}

// lists to a slice of tools. Unknown names are ignored. Required tools are
// always preserved.
func filterBuiltinTools(tools []odek.Tool, cfg config.ToolConfig, required map[string]bool) []odek.Tool {
Expand Down
79 changes: 54 additions & 25 deletions cmd/odek/subagent.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ Tool conventions — use the dedicated tool, NOT shell:
- Reserve shell for builds, installs, git, scripts. Don't run uname/pwd/date/whoami —
read your Runtime Context header.

Report what you built, what files changed, and any issues. Be concise, then stop.`
End with a short headline: status, artifact file names, key decisions.
The files carry the detail. Be concise, then stop.`

// subagentAmendments translates the pillar's principal-facing rules into
// sub-agent terms: a child has no channel to the principal and no approval
Expand Down Expand Up @@ -133,12 +134,12 @@ func neutraliseSubagentInputLiterals(s string) string {
// optional — old children ignore the flags (version skew keeps the old
// clamping behavior) and old parents simply never emit them.
type taskBudget struct {
MaxRuntimeSeconds int64 `json:"max_runtime_seconds,omitempty"`
MaxToolCalls int64 `json:"max_tool_calls,omitempty"`
MaxCostUSD float64 `json:"max_cost_usd,omitempty"`
RuntimeExhausted bool `json:"runtime_exhausted,omitempty"`
ToolCallsExhausted bool `json:"tool_calls_exhausted,omitempty"`
CostExhausted bool `json:"cost_exhausted,omitempty"`
MaxRuntimeSeconds int64 `json:"max_runtime_seconds,omitempty"`
MaxToolCalls int64 `json:"max_tool_calls,omitempty"`
MaxCostUSD float64 `json:"max_cost_usd,omitempty"`
RuntimeExhausted bool `json:"runtime_exhausted,omitempty"`
ToolCallsExhausted bool `json:"tool_calls_exhausted,omitempty"`
CostExhausted bool `json:"cost_exhausted,omitempty"`
}

// clampLimits narrows the operator limits by the parent-supplied task
Expand Down Expand Up @@ -537,19 +538,21 @@ type subagentWireContext struct {

// subagentResult is the JSON contract written to stdout.
type subagentResult struct {
Status string `json:"status"` // "success", "partial", "budget_exhausted" or "error"
Error string `json:"error,omitempty"` // error message
PartialReason string `json:"partial_reason,omitempty"` // time_budget | iteration_budget | execution_budget
Summary string `json:"summary"` // task summary
FilesChanged []string `json:"files_changed,omitempty"` // changed files
TokensUsed int `json:"tokens_used"` // total tokens consumed
Iterations int `json:"iterations"` // think-act cycles used
DurationSeconds float64 `json:"duration_seconds"` // wall-clock runtime
Denials []SubagentDenial `json:"denials,omitempty"` // policy denials observed (capped)
DenialsTotal int `json:"denials_total,omitempty"` // total denials seen
ParentSession string `json:"parent_session,omitempty"` // correlation id from --parent-session
CostUSD float64 `json:"cost_usd,omitempty"` // final server-side cost estimate (omitted when no prices configured)
Artifacts []artifact.Ref `json:"artifacts,omitempty"` // odek.artifact-ref/v1 — runner-scanned, parent-validated
Status string `json:"status"` // "success", "partial", "budget_exhausted" or "error"
Error string `json:"error,omitempty"` // error message
PartialReason string `json:"partial_reason,omitempty"` // time_budget | iteration_budget | execution_budget
Summary string `json:"summary"` // task summary (headline channel — capped)
SummaryTruncated bool `json:"summary_truncated,omitempty"` // headline was cut — parent should fetch artifacts (C)
SummaryRunes int `json:"summary_runes,omitempty"` // ORIGINAL headline rune count before the cap
FilesChanged []string `json:"files_changed,omitempty"` // changed files
TokensUsed int `json:"tokens_used"` // total tokens consumed
Iterations int `json:"iterations"` // think-act cycles used
DurationSeconds float64 `json:"duration_seconds"` // wall-clock runtime
Denials []SubagentDenial `json:"denials,omitempty"` // policy denials observed (capped)
DenialsTotal int `json:"denials_total,omitempty"` // total denials seen
ParentSession string `json:"parent_session,omitempty"` // correlation id from --parent-session
CostUSD float64 `json:"cost_usd,omitempty"` // final server-side cost estimate (omitted when no prices configured)
Artifacts []artifact.Ref `json:"artifacts,omitempty"` // odek.artifact-ref/v1 — runner-scanned, parent-validated
}

// ── Subagent Command ─────────────────────────────────────────────────
Expand Down Expand Up @@ -1084,7 +1087,7 @@ func subagentCmd(args []string) error {
// Classify the outcome (M1.3/M2.4 contract): typed budget errors map to
// budget_exhausted, partial-summary markers to partial (with reason),
// hard timeouts to error+timeout, everything else to success/error.
summary := extractSummary(allMessages)
summary, summaryRunes, summaryTruncated := extractSummaryInfo(allMessages)
reason, partial := loop.PartialSummaryReason(summary)
outcome := classifySubagentRun(err, partial, reason, sigCtx)

Expand All @@ -1098,6 +1101,10 @@ func subagentCmd(args []string) error {
DurationSeconds: latency.Seconds(),
ParentSession: cfg.parentSession,
}
if summaryTruncated {
result.SummaryTruncated = true
result.SummaryRunes = summaryRunes
}

if err != nil {
if outcome.TimedOut {
Expand Down Expand Up @@ -1298,14 +1305,36 @@ func (e *subagentRunError) Error() string {
// 500-rune cut.
const subagentHeadlineMaxRunes = 2048

func extractSummary(messages []llm.Message) string {
// Return the last assistant message content as summary
// extractSummaryInfo returns the child's final answer cut to the headline
// cap, the ORIGINAL rune count, and whether it was cut (C — the parent
// render turns this into a visible truncation marker). The bulk channel is
// the artifact protocol; the headline is a status summary.
func extractSummaryInfo(messages []llm.Message) (string, int, bool) {
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role == "assistant" && messages[i].Content != "" {
return truncate(messages[i].Content, subagentHeadlineMaxRunes)
s, total := truncateWithLen(messages[i].Content, subagentHeadlineMaxRunes)
return s, total, total > subagentHeadlineMaxRunes
}
}
return ""
return "", 0, false
}

func extractSummary(messages []llm.Message) string {
s, _, _ := extractSummaryInfo(messages)
return s
}

// truncateWithLen cuts s to n runes (appending "…") and reports the
// ORIGINAL rune count so callers can surface a truncation marker (C).
func truncateWithLen(s string, n int) (string, int) {
runes := []rune(s)
if n <= 0 {
return "…", len(runes)
}
if len(runes) <= n {
return s, len(runes)
}
return string(runes[:n]) + "…", len(runes)
}

func extractFilesChanged(messages []llm.Message) []string {
Expand Down
Loading
Loading