Skip to content
Open
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
5 changes: 3 additions & 2 deletions packages/harness/acp/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,8 @@ func (h *Handler) Prompt(ctx context.Context, params acpsdk.PromptRequest) (acps
conn: h.conn,
sessionID: params.SessionId,
}
cfg.Stream = true // OnToken() streams content; suppress duplicate FinalMessage send
_, canStream := h.provider.(llm.StreamProvider)
cfg.Stream = canStream // OnToken() streams content; suppress duplicate FinalMessage send only when supported.

// Carry forward conversation history from previous Prompt() calls.
h.mu.Lock()
Expand Down Expand Up @@ -299,7 +300,7 @@ func (e *acpEventHandler) OnToolEnd(id string, name string, result string, isErr
})
}

func (e *acpEventHandler) OnTurnStart(turn, maxTurns int) {}
func (e *acpEventHandler) OnTurnStart(turn, maxTurns int) {}
func (e *acpEventHandler) OnTurnEnd(turn int, toolCallCount int) {}

// extractText concatenates all text content blocks from the prompt.
Expand Down
49 changes: 49 additions & 0 deletions packages/harness/agent/handoff.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package agent

import (
"context"
"time"

"github.com/workspace/harness/handoff"
"github.com/workspace/harness/llm"
"github.com/workspace/harness/transcript"
)

const handoffTimeout = 30 * time.Second

func emitHandoff(ctx context.Context, provider llm.Provider, log *transcript.Log, cfg Config, userPrompt string, result *Result) *Result {
if result == nil || !cfg.HandoffEnabled {
return result
}

handoffCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), handoffTimeout)
defer cancel()

packet := handoff.Generate(handoffCtx, provider, handoff.Input{
MissionID: cfg.HandoffMissionID,
FromTaskID: cfg.HandoffFromTaskID,
ToTaskID: cfg.HandoffToTaskID,
SessionID: cfg.SessionID,
TaskPrompt: userPrompt,
TerminalStatus: terminalStatus(result.StopReason),
StopReason: result.StopReason,
TurnsUsed: result.TurnsUsed,
Messages: result.Messages,
Transcript: log,
TranscriptPath: cfg.HandoffTranscriptPath,
WorkDir: cfg.WorkDir,
})
result.Handoff = &packet
return result
}

func terminalStatus(stopReason string) handoff.TerminalStatus {
switch stopReason {
case "complete":
return handoff.StatusSuccess
case "error":
return handoff.StatusError
default:
return handoff.StatusIncomplete
}
}
165 changes: 165 additions & 0 deletions packages/harness/agent/handoff_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package agent

import (
"context"
"errors"
"testing"

"github.com/workspace/harness/llm"
"github.com/workspace/harness/tools"
"github.com/workspace/harness/transcript"
)

func TestRunHandoffHappyPath(t *testing.T) {
provider := llm.NewMockProvider(
&llm.Response{Content: "Done."},
&llm.Response{Content: `{
"summary": "The session completed the task.",
"facts": [{"key": "result", "value": "done"}],
"openQuestions": [],
"suggestedActions": ["Review changes"]
}`},
)
result, err := Run(context.Background(), provider, tools.NewRegistry(), transcript.NewLog(), Config{
MaxTurns: 5,
HandoffEnabled: true,
HandoffMissionID: "mission-1",
HandoffFromTaskID: "task-1",
HandoffTranscriptPath: "transcript.json",
}, "do the work")
if err != nil {
t.Fatalf("Run error: %v", err)
}
if result.StopReason != "complete" {
t.Fatalf("stop reason = %s", result.StopReason)
}
if result.Handoff == nil {
t.Fatal("handoff was not generated")
}
if result.Handoff.Summary != "The session completed the task." {
t.Fatalf("handoff summary = %q", result.Handoff.Summary)
}
if provider.CallCount() != 2 {
t.Fatalf("provider calls = %d, want 2", provider.CallCount())
}
}

func TestRunHandoffMalformedJSONDoesNotAffectExit(t *testing.T) {
provider := llm.NewMockProvider(
&llm.Response{Content: "Done."},
&llm.Response{Content: "not json"},
)
result, err := Run(context.Background(), provider, tools.NewRegistry(), transcript.NewLog(), Config{
MaxTurns: 5,
HandoffEnabled: true,
}, "finish")
if err != nil {
t.Fatalf("Run error: %v", err)
}
if result.StopReason != "complete" {
t.Fatalf("stop reason = %s", result.StopReason)
}
if result.Handoff == nil {
t.Fatal("handoff was not generated")
}
if !handoffFact(result, "terminalStatus", "success") {
t.Fatalf("handoff fallback facts = %#v", result.Handoff.Facts)
}
}

func TestRunHandoffTerminalStatuses(t *testing.T) {

Check failure on line 70 in packages/harness/agent/handoff_test.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 24 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=raphaeltm_simple-agent-manager&issues=AZ8tLginN3xCn8X3UPMD&open=AZ8tLginN3xCn8X3UPMD&pullRequest=1491
tests := []struct {
name string
run func(t *testing.T) (*Result, error)
wantStop string
wantStatus string
wantErr bool
}{
{
name: "success",
run: func(t *testing.T) (*Result, error) {
provider := llm.NewMockProvider(
&llm.Response{Content: "Done."},
&llm.Response{Content: `not json`},
)
return Run(context.Background(), provider, tools.NewRegistry(), transcript.NewLog(), Config{MaxTurns: 2, HandoffEnabled: true}, "task")
},
wantStop: "complete",
wantStatus: "success",
},
{
name: "incomplete",
run: func(t *testing.T) (*Result, error) {
provider := llm.NewMockProvider(
&llm.Response{ToolCalls: []llm.ToolCall{{ID: "echo-1", Name: "echo", Params: map[string]any{"text": "loop"}}}},
&llm.Response{Content: `not json`},
)
registry := tools.NewRegistry()
if err := registry.Register(&echoTool{}); err != nil {
t.Fatalf("register echo: %v", err)
}
return Run(context.Background(), provider, registry, transcript.NewLog(), Config{MaxTurns: 1, HandoffEnabled: true}, "task")
},
wantStop: "max_turns",
wantStatus: "incomplete",
},
{
name: "error",
run: func(t *testing.T) (*Result, error) {
provider := &firstCallErrorProvider{}
return Run(context.Background(), provider, tools.NewRegistry(), transcript.NewLog(), Config{MaxTurns: 2, HandoffEnabled: true}, "task")
},
wantStop: "error",
wantStatus: "error",
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := tt.run(t)
if tt.wantErr && err == nil {
t.Fatal("expected Run error")
}
if !tt.wantErr && err != nil {
t.Fatalf("Run error: %v", err)
}
if result == nil {
t.Fatal("result is nil")
}
if result.StopReason != tt.wantStop {
t.Fatalf("stop reason = %s, want %s", result.StopReason, tt.wantStop)
}
if result.Handoff == nil {
t.Fatal("handoff was not generated")
}
if !handoffFact(result, "terminalStatus", tt.wantStatus) && result.Handoff.Summary == "" {
t.Fatalf("handoff missing status %q: %#v", tt.wantStatus, result.Handoff)
}
})
}
}

type firstCallErrorProvider struct {
calls int
}

func (p *firstCallErrorProvider) SendMessage(context.Context, []llm.Message, []llm.ToolDefinition) (*llm.Response, error) {
p.calls++
if p.calls == 1 {
return nil, errors.New("primary LLM failed")
}
return &llm.Response{Content: `not json`}, nil
}

func handoffFact(result *Result, key, value string) bool {
if result == nil || result.Handoff == nil {
return false
}
for _, fact := range result.Handoff.Facts {
if fact.Key == key && fact.Value == value {
return true
}
}
return false
}
17 changes: 16 additions & 1 deletion packages/harness/agent/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"sync"

ctxmgr "github.com/workspace/harness/context"
"github.com/workspace/harness/handoff"
"github.com/workspace/harness/llm"
"github.com/workspace/harness/session"
"github.com/workspace/harness/tools"
Expand Down Expand Up @@ -78,6 +79,16 @@
SessionStore *session.Store
// SessionID is the session to persist messages to. Required if SessionStore is set.
SessionID string
// HandoffEnabled emits a platform-shaped handoff packet at terminal state.
HandoffEnabled bool
// HandoffTranscriptPath is included as the transcript artifact ref when set.
HandoffTranscriptPath string
// HandoffMissionID maps to the platform HandoffPacket missionId.
HandoffMissionID string
// HandoffFromTaskID maps to the platform HandoffPacket fromTaskId.
HandoffFromTaskID string
// HandoffToTaskID maps to the platform HandoffPacket toTaskId.
HandoffToTaskID *string
// InitialMessages, if non-empty, seeds the conversation instead of building
// a fresh system+user pair. The new userPrompt is appended as a user message.
// This allows callers (e.g. ACP handler) to carry conversation history
Expand Down Expand Up @@ -106,11 +117,15 @@
// Callers can feed this back via Config.InitialMessages to continue
// the conversation in a subsequent Run() call.
Messages []llm.Message
// Handoff is the structured terminal session handoff, when enabled.
Handoff *handoff.Packet
}

// Run executes the agent loop: think -> act -> observe, repeating until
// the model stops calling tools or max turns is reached.
func Run(ctx context.Context, provider llm.Provider, registry *tools.Registry, log *transcript.Log, cfg Config, userPrompt string) (*Result, error) {
func Run(ctx context.Context, provider llm.Provider, registry *tools.Registry, log *transcript.Log, cfg Config, userPrompt string) (result *Result, err error) {

Check failure on line 126 in packages/harness/agent/loop.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 57 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=raphaeltm_simple-agent-manager&issues=AZ8tLgYhN3xCn8X3UPMC&open=AZ8tLgYhN3xCn8X3UPMC&pullRequest=1491
defer func() { result = emitHandoff(ctx, provider, log, cfg, userPrompt, result) }()

maxTurns := cfg.MaxTurns
if maxTurns <= 0 {
maxTurns = 10
Expand Down
49 changes: 35 additions & 14 deletions packages/harness/cmd/harness/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

acpserver "github.com/workspace/harness/acp"
"github.com/workspace/harness/agent"
"github.com/workspace/harness/handoff"
"github.com/workspace/harness/llm"
"github.com/workspace/harness/mcp"
"github.com/workspace/harness/prompts"
Expand All @@ -28,6 +29,8 @@
maxTurns = flag.Int("max-turns", 10, "Maximum agent loop iterations")
maxContextTokens = flag.Int("max-context-tokens", 30000, "Maximum context window tokens before compaction")
transcriptF = flag.String("transcript", "", "Path to write transcript JSON")
handoffEnabled = flag.Bool("handoff", true, "Write a SAM HandoffPacket JSON artifact at session end")
handoffPath = flag.String("handoff-path", "", "Path to write handoff JSON (default: sidecar next to transcript, or .sam-harness/handoffs/)")
systemPrompt = flag.String("system", "You are a coding assistant. Use the provided tools to complete tasks.", "System prompt (lowest precedence)")
promptFile = flag.String("prompt-file", "", "Path to a markdown file to use as system prompt (highest precedence)")
promptPreset = flag.String("prompt-preset", "", "Built-in prompt preset: workspace, orchestrator")
Expand Down Expand Up @@ -58,7 +61,7 @@
dbPath := *sessionDB
if dbPath == "" {
home, _ := os.UserHomeDir()
dbPath = filepath.Join(home, ".sam-harness", "sessions.db")

Check failure on line 64 in packages/harness/cmd/harness/main.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal ".sam-harness" 3 times.

See more on https://sonarcloud.io/project/issues?id=raphaeltm_simple-agent-manager&issues=AZ8tLgjLN3xCn8X3UPMG&open=AZ8tLgjLN3xCn8X3UPMG&pullRequest=1491
}
store, err := session.NewStore(dbPath)
if err != nil {
Expand Down Expand Up @@ -316,19 +319,23 @@
defer cancel()

result, err := agent.Run(ctx, provider, registry, log, agent.Config{
SystemPrompt: sysPrompt,
MaxTurns: *maxTurns,
MaxContextTokens: *maxContextTokens,
CompactionStrategy: agent.CompactionStrategy(*compactionStrat),
WorkerModel: resolvedWorkerModel,
WorkDir: workDir,
Stream: *stream,
PermissionMode: permMode,
PermissionChecker: tools.AutoApproveChecker{},
ParallelTools: *parallelTools,
MaxParallelTools: *maxParallelTools,
SessionStore: sessionStore,
SessionID: sessionID,
SystemPrompt: sysPrompt,
MaxTurns: *maxTurns,
MaxContextTokens: *maxContextTokens,
CompactionStrategy: agent.CompactionStrategy(*compactionStrat),
WorkerModel: resolvedWorkerModel,
WorkDir: workDir,
Stream: *stream,
PermissionMode: permMode,
PermissionChecker: tools.AutoApproveChecker{},
ParallelTools: *parallelTools,
MaxParallelTools: *maxParallelTools,
SessionStore: sessionStore,
SessionID: sessionID,
HandoffEnabled: *handoffEnabled,
HandoffTranscriptPath: *transcriptF,
HandoffMissionID: envOr("SAM_MISSION_ID", ""),
HandoffFromTaskID: envOr("SAM_TASK_ID", ""),
ProviderConfig: &agent.ProviderConfig{
Name: *providerName,
APIURL: *apiURL,
Expand Down Expand Up @@ -365,6 +372,21 @@
}
fmt.Printf("Transcript written to %s (%d events)\n", *transcriptF, log.Len())
}

if *handoffEnabled && result != nil && result.Handoff != nil {
path := *handoffPath
if path == "" {
path = handoff.HandoffPathForTranscript(*transcriptF)
}
if path == "" {
path = filepath.Join(workDir, ".sam-harness", "handoffs", fmt.Sprintf("%d.handoff.json", time.Now().UnixNano()))
}
if err := handoff.Save(path, *result.Handoff); err != nil {
fmt.Fprintf(os.Stderr, "error writing handoff: %v\n", err)
os.Exit(1)
}
fmt.Printf("Handoff written to %s\n", path)
}
}

// isGitRepo checks whether dir (or an ancestor) is a git repository.
Expand Down Expand Up @@ -587,4 +609,3 @@
}
return registry
}

Loading