Skip to content

Commit e7be5fe

Browse files
authored
Merge pull request #16 from rebelopsio/diag/agent-stderr-and-options
chore(agent): log SDK options and capture claude subprocess stderr
2 parents ec7ceaa + 8a91d2a commit e7be5fe

5 files changed

Lines changed: 103 additions & 3 deletions

File tree

cmd/daily_run.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,9 @@ func explainAgentOutcome(res *agent.RunResult, statErr error) error {
244244
} else {
245245
fmt.Fprintf(&b, "\nagent said: <empty>")
246246
}
247+
if res.SubprocessStderr != "" {
248+
fmt.Fprintf(&b, "\nclaude stderr: %s", truncateText(res.SubprocessStderr, 2000))
249+
}
247250
return errors.New(b.String())
248251
}
249252

cmd/daily_run_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,23 @@ func TestRunDaily_AgentTextTruncated(t *testing.T) {
491491
assert.Less(t, len(msg), 2000, "error message should be bounded")
492492
}
493493

494+
// Subprocess stderr captured by the SDK is surfaced in the
495+
// verification-failure error so the operator can see what claude
496+
// actually wrote before exiting.
497+
func TestRunDaily_VerificationErrorIncludesSubprocessStderr(t *testing.T) {
498+
deps, gatherer, runtime, _ := fixtureDeps(t)
499+
gatherer.issues = []domain.Issue{{Ref: domain.ExternalRef{Provider: "linear", ID: "X"}, Title: "x"}}
500+
501+
runtime.result = &agent.RunResult{
502+
SubprocessStderr: "Error: unknown flag --bogus",
503+
}
504+
505+
_, err := runDaily(context.Background(), deps, dailyOptions{})
506+
require.Error(t, err)
507+
assert.Contains(t, err.Error(), "claude stderr:")
508+
assert.Contains(t, err.Error(), "unknown flag --bogus")
509+
}
510+
494511
// Turns, duration, and cost from the SDK are surfaced so the operator
495512
// can tell whether the model was consulted at all when both tool
496513
// calls and text are empty.

internal/agent/run.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,15 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"io"
8+
"sort"
79
"strings"
10+
"sync"
811
"time"
912

1013
claude "github.com/partio-io/claude-agent-sdk-go"
14+
15+
"github.com/rebelopsio/archy/internal/config"
1116
)
1217

1318
// RunRequest describes a single skill execution.
@@ -45,6 +50,10 @@ type RunResult struct {
4550
// CostUSD is the model's reported cost for this run, if available.
4651
// Zero means unknown.
4752
CostUSD float64
53+
// SubprocessStderr is everything the claude CLI subprocess wrote
54+
// to its stderr during this run, joined newline-per-callback.
55+
// Empty when the subprocess produced no stderr output.
56+
SubprocessStderr string
4857
}
4958

5059
// ToolCallRecord is one tool invocation observed during a run.
@@ -85,6 +94,23 @@ func (r *Runtime) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
8594
}
8695
opts = append(opts, claude.WithAppendSystemPrompt(systemPromptAddition(req)))
8796

97+
// Capture the claude CLI subprocess's stderr so silent-failure
98+
// modes (subprocess exits before consulting the model) surface
99+
// the underlying error. The callback fires from the SDK's drain
100+
// goroutine, so guard the buffer with a mutex.
101+
var (
102+
stderrMu sync.Mutex
103+
stderrBuf strings.Builder
104+
)
105+
opts = append(opts, claude.WithStderrCallback(func(line string) {
106+
stderrMu.Lock()
107+
defer stderrMu.Unlock()
108+
stderrBuf.WriteString(line)
109+
stderrBuf.WriteByte('\n')
110+
}))
111+
112+
logSDKInvocation(r.stderrLog, r.cfg, r.opts, opts, req)
113+
88114
emit := func(ev ProgressEvent) {
89115
if req.ProgressFn != nil {
90116
req.ProgressFn(ev)
@@ -95,6 +121,14 @@ func (r *Runtime) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
95121
emit(ProgressEvent{Kind: ProgressStart, At: start})
96122

97123
res := &RunResult{}
124+
// readStderr returns the captured subprocess output. Called on
125+
// every return path so RunResult.SubprocessStderr is always
126+
// populated (empty when nothing was written).
127+
readStderr := func() string {
128+
stderrMu.Lock()
129+
defer stderrMu.Unlock()
130+
return stderrBuf.String()
131+
}
98132
pending := make(map[string]*ToolCallRecord) // tool_use_id → in-flight record
99133
var assistantText strings.Builder
100134
systemSeen := false
@@ -105,6 +139,11 @@ func (r *Runtime) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
105139
if ctx.Err() != nil && (errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) {
106140
return nil, fmt.Errorf("agent run canceled: %w", ctx.Err())
107141
}
142+
// Surface the subprocess stderr alongside the SDK error so
143+
// the operator sees what claude actually said before exiting.
144+
if s := readStderr(); s != "" {
145+
return nil, fmt.Errorf("%w: %v (claude stderr: %s)", ErrRun, err, s)
146+
}
108147
return nil, fmt.Errorf("%w: %v", ErrRun, err)
109148
}
110149
if ctx.Err() != nil {
@@ -143,17 +182,47 @@ func (r *Runtime) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
143182
emit(ProgressEvent{Kind: ProgressEnd, Message: endMsg, At: time.Now()})
144183
res.Text = assistantText.String()
145184
res.Duration = time.Since(start)
185+
res.SubprocessStderr = readStderr()
146186
return res, fmt.Errorf("%w: %s", ErrRun, endMsg)
147187
}
148188
}
149189
}
150190

151191
res.Text = assistantText.String()
152192
res.Duration = time.Since(start)
193+
res.SubprocessStderr = readStderr()
153194
emit(ProgressEvent{Kind: ProgressEnd, Message: "completed", At: time.Now()})
154195
return res, nil
155196
}
156197

198+
// logSDKInvocation writes a one-time summary of the agent invocation
199+
// to w. This is the last thing archy controls before subprocess
200+
// handoff; if claude exits without consulting the model, the answer
201+
// is almost certainly in what we passed it. Secrets (bearer tokens,
202+
// auth headers) are never logged.
203+
func logSDKInvocation(w io.Writer, cfg *config.Config, opts Options, sdkOpts []claude.Option, req RunRequest) {
204+
mcpEnabled := []string{}
205+
for name, srv := range cfg.MCPServers {
206+
if srv.Enabled {
207+
mcpEnabled = append(mcpEnabled, name)
208+
}
209+
}
210+
sort.Strings(mcpEnabled)
211+
212+
_, _ = fmt.Fprintln(w, "archy agent invocation:")
213+
_, _ = fmt.Fprintf(w, " skill=%s\n", req.SkillName)
214+
_, _ = fmt.Fprintf(w, " model=%s\n", cfg.Agent.Model)
215+
_, _ = fmt.Fprintf(w, " max_turns=%d\n", cfg.Agent.MaxTurns)
216+
_, _ = fmt.Fprintf(w, " permission_mode=%s\n", cfg.Agent.PermissionMode)
217+
_, _ = fmt.Fprintf(w, " cwd=%s\n", opts.Cwd)
218+
_, _ = fmt.Fprintf(w, " cli_path=%s\n", opts.CLIPath)
219+
_, _ = fmt.Fprintf(w, " archy_binary=%s\n", opts.ArchyBinaryPath)
220+
_, _ = fmt.Fprintf(w, " sdk_option_count=%d\n", len(sdkOpts))
221+
_, _ = fmt.Fprintf(w, " mcp_servers_enabled=%v\n", mcpEnabled)
222+
_, _ = fmt.Fprintf(w, " skills_project_dir=%s\n", cfg.Skills.ProjectDir)
223+
_, _ = fmt.Fprintf(w, " skills_user_dir=%s\n", cfg.Skills.UserDir)
224+
}
225+
157226
// systemPromptAddition is the one-line skill-invocation instruction the
158227
// runtime appends via [claude.WithAppendSystemPrompt]. Skill authors
159228
// can rely on the agent seeing this exact phrasing.

internal/agent/runtime.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package agent
33
import (
44
"context"
55
"fmt"
6+
"io"
67
"iter"
78
"os"
89
"sync"
@@ -23,6 +24,11 @@ type Runtime struct {
2324
// the iter.Seq2 yielded by Stream.
2425
runner runner
2526

27+
// stderrLog is where the agent writes operational diagnostic lines
28+
// (SDK invocation summary, subprocess stderr). Defaults to
29+
// os.Stderr; tests override with io.Discard or a buffer.
30+
stderrLog io.Writer
31+
2632
// closeOnce guards Close from being called multiple times.
2733
closeOnce sync.Once
2834
}
@@ -75,9 +81,10 @@ func New(opts Options) (*Runtime, error) {
7581
}
7682

7783
return &Runtime{
78-
cfg: opts.Config,
79-
opts: opts,
80-
runner: realRunner{},
84+
cfg: opts.Config,
85+
opts: opts,
86+
runner: realRunner{},
87+
stderrLog: os.Stderr,
8188
}, nil
8289
}
8390

internal/agent/runtime_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package agent
33
import (
44
"context"
55
"errors"
6+
"io"
67
"iter"
78
"testing"
89
"time"
@@ -54,6 +55,8 @@ func (f *fakeRunner) run(ctx context.Context, prompt string, opts []claude.Optio
5455

5556
// newTestRuntime returns a Runtime with a baseline-valid config and a
5657
// substituted fake runner. The caller drives the fakeRunner's messages.
58+
// stderrLog is replaced with io.Discard to keep test output quiet —
59+
// the invocation log fires on every Run.
5760
func newTestRuntime(t *testing.T, fr *fakeRunner) *Runtime {
5861
t.Helper()
5962
rt, err := New(Options{
@@ -63,6 +66,7 @@ func newTestRuntime(t *testing.T, fr *fakeRunner) *Runtime {
6366
})
6467
require.NoError(t, err)
6568
rt.runner = fr
69+
rt.stderrLog = io.Discard
6670
return rt
6771
}
6872

0 commit comments

Comments
 (0)