diff --git a/packages/harness/acp/handler.go b/packages/harness/acp/handler.go index bc8aed513..358c12135 100644 --- a/packages/harness/acp/handler.go +++ b/packages/harness/acp/handler.go @@ -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() @@ -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. diff --git a/packages/harness/agent/handoff.go b/packages/harness/agent/handoff.go new file mode 100644 index 000000000..613763c10 --- /dev/null +++ b/packages/harness/agent/handoff.go @@ -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 + } +} diff --git a/packages/harness/agent/handoff_test.go b/packages/harness/agent/handoff_test.go new file mode 100644 index 000000000..c105ccb98 --- /dev/null +++ b/packages/harness/agent/handoff_test.go @@ -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) { + 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 +} diff --git a/packages/harness/agent/loop.go b/packages/harness/agent/loop.go index 7f13e160f..087ba0e49 100644 --- a/packages/harness/agent/loop.go +++ b/packages/harness/agent/loop.go @@ -8,6 +8,7 @@ import ( "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" @@ -78,6 +79,16 @@ type Config struct { 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 @@ -106,11 +117,15 @@ type Result struct { // 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) { + defer func() { result = emitHandoff(ctx, provider, log, cfg, userPrompt, result) }() + maxTurns := cfg.MaxTurns if maxTurns <= 0 { maxTurns = 10 diff --git a/packages/harness/cmd/harness/main.go b/packages/harness/cmd/harness/main.go index 31e8d08e8..8a7abb124 100644 --- a/packages/harness/cmd/harness/main.go +++ b/packages/harness/cmd/harness/main.go @@ -12,6 +12,7 @@ import ( 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" @@ -28,6 +29,8 @@ func main() { 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") @@ -316,19 +319,23 @@ func main() { 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, @@ -365,6 +372,21 @@ func main() { } 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. @@ -587,4 +609,3 @@ func buildToolRegistry(args acpModeArgs, workDir string) *tools.Registry { } return registry } - diff --git a/packages/harness/handoff/generate.go b/packages/harness/handoff/generate.go new file mode 100644 index 000000000..be4e22212 --- /dev/null +++ b/packages/harness/handoff/generate.go @@ -0,0 +1,348 @@ +package handoff + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/workspace/harness/llm" + "github.com/workspace/harness/transcript" +) + +const ( + currentVersion = 1 +) + +// TerminalStatus is the harness-normalized session terminal status. +type TerminalStatus string + +const ( + StatusSuccess TerminalStatus = "success" + StatusIncomplete TerminalStatus = "incomplete" + StatusError TerminalStatus = "error" +) + +// Input is the complete set of harness state used to generate a handoff. +type Input struct { + MissionID string + FromTaskID string + ToTaskID *string + SessionID string + TaskPrompt string + TerminalStatus TerminalStatus + StopReason string + TurnsUsed int + Messages []llm.Message + Transcript *transcript.Log + TranscriptPath string + WorkDir string + Now time.Time +} + +// Generate creates a platform-shaped handoff packet. LLM failures and malformed +// structured output never fail generation; they return a mechanical fallback. +func Generate(ctx context.Context, provider llm.Provider, in Input) Packet { + now := in.Now + if now.IsZero() { + now = time.Now().UTC() + } + + artifacts := artifactRefs(in) + fallback := fallbackPacket(in, artifacts, now, "mechanical fallback") + if provider == nil { + return fallback + } + + resp, err := provider.SendMessage(ctx, handoffPrompt(in), nil) + if err != nil { + return fallbackPacket(in, artifacts, now, fmt.Sprintf("LLM handoff generation failed: %v", err)) + } + + structured, err := parseStructured(resp.Content) + if err != nil || strings.TrimSpace(structured.Summary) == "" { + reason := "LLM handoff generation returned malformed JSON" + if err != nil { + reason = err.Error() + } + return fallbackPacket(in, artifacts, now, reason) + } + + return Packet{ + ID: packetID(now), + MissionID: in.MissionID, + FromTaskID: fromTaskID(in), + ToTaskID: in.ToTaskID, + Summary: strings.TrimSpace(structured.Summary), + Facts: cleanFacts(structured.Facts), + OpenQuestions: cleanStrings(structured.OpenQuestions), + ArtifactRefs: artifacts, + SuggestedActions: cleanStrings(structured.SuggestedActions), + Version: currentVersion, + CreatedAt: now.UnixMilli(), + } +} + +type structuredOutput struct { + Summary string `json:"summary"` + Facts []Fact `json:"facts"` + OpenQuestions []string `json:"openQuestions"` + SuggestedActions []string `json:"suggestedActions"` +} + +func handoffPrompt(in Input) []llm.Message { + var b strings.Builder + b.WriteString("Generate a SAM session handoff packet as strict JSON.\n") + b.WriteString("Return exactly this object shape and no markdown: ") + b.WriteString(`{"summary":"...","facts":[{"key":"...","value":"..."}],"openQuestions":["..."],"suggestedActions":["..."]}`) + b.WriteString("\n\nSummarize what was attempted and accomplished. Extract durable facts, unresolved questions, and suggested next actions.\n") + fmt.Fprintf(&b, "\nTask prompt:\n%s\n", in.TaskPrompt) + fmt.Fprintf(&b, "\nTerminal status: %s\nStop reason: %s\nTurns used: %d\n", in.TerminalStatus, in.StopReason, in.TurnsUsed) + b.WriteString("\nConversation excerpt:\n") + for _, msg := range tailMessages(in.Messages, 12) { + content := strings.TrimSpace(msg.Content) + if content == "" && msg.ToolResult != nil { + content = msg.ToolResult.Content + } + if content == "" { + continue + } + fmt.Fprintf(&b, "- %s: %s\n", msg.Role, truncate(content, 1200)) + } + + return []llm.Message{ + {Role: llm.RoleSystem, Content: "You produce strict JSON for a coding-agent handoff. Do not include markdown fences."}, + {Role: llm.RoleUser, Content: b.String()}, + } +} + +func parseStructured(content string) (structuredOutput, error) { + var out structuredOutput + raw := strings.TrimSpace(content) + raw = strings.TrimPrefix(raw, "```json") + raw = strings.TrimPrefix(raw, "```") + raw = strings.TrimSuffix(raw, "```") + raw = strings.TrimSpace(raw) + if err := json.Unmarshal([]byte(raw), &out); err != nil { + return structuredOutput{}, fmt.Errorf("LLM handoff generation returned malformed JSON: %w", err) + } + return out, nil +} + +func fallbackPacket(in Input, artifacts []ArtifactRef, now time.Time, reason string) Packet { + facts := []Fact{ + {Key: "terminalStatus", Value: string(in.TerminalStatus)}, + {Key: "stopReason", Value: in.StopReason}, + {Key: "turnsUsed", Value: fmt.Sprintf("%d", in.TurnsUsed)}, + } + if strings.TrimSpace(reason) != "" { + facts = append(facts, Fact{Key: "handoffGeneration", Value: reason}) + } + summary := fmt.Sprintf("Task prompt: %s\n\nSession ended with status %s after %d turns.", + strings.TrimSpace(in.TaskPrompt), in.TerminalStatus, in.TurnsUsed) + + return Packet{ + ID: packetID(now), + MissionID: in.MissionID, + FromTaskID: fromTaskID(in), + ToTaskID: in.ToTaskID, + Summary: summary, + Facts: facts, + OpenQuestions: []string{"Review the transcript for details not captured in the mechanical fallback."}, + ArtifactRefs: artifacts, + SuggestedActions: []string{"Review the transcript and continue from the recorded terminal status."}, + Version: currentVersion, + CreatedAt: now.UnixMilli(), + } +} + +func artifactRefs(in Input) []ArtifactRef { + seen := map[string]bool{} + var refs []ArtifactRef + add := func(ref ArtifactRef) { + if strings.TrimSpace(ref.Ref) == "" { + return + } + key := string(ref.Type) + "\x00" + ref.Ref + if seen[key] { + return + } + seen[key] = true + refs = append(refs, ref) + } + + add(ArtifactRef{Type: ArtifactFile, Ref: in.TranscriptPath, Description: "Session transcript"}) + for _, path := range modifiedFiles(in.Transcript) { + add(ArtifactRef{Type: ArtifactFile, Ref: path, Description: "File modified during session"}) + } + if branch := gitBranch(in.WorkDir); branch != "" { + add(ArtifactRef{Type: ArtifactBranch, Ref: branch, Description: "Git branch at session end"}) + } + return refs +} + +func modifiedFiles(log *transcript.Log) []string { + if log == nil { + return nil + } + toolCalls := map[string]struct { + name string + params map[string]any + }{} + success := map[string]bool{} + + for _, event := range log.Events() { + data, ok := event.Data.(map[string]any) + if !ok { + continue + } + switch event.Type { + case transcript.EventToolCall: + id, _ := data["id"].(string) + name, _ := data["name"].(string) + params, _ := data["params"].(map[string]any) + if id != "" { + toolCalls[id] = struct { + name string + params map[string]any + }{name: name, params: params} + } + case transcript.EventToolResult: + id, _ := data["call_id"].(string) + isError, _ := data["is_error"].(bool) + if id != "" && !isError { + success[id] = true + } + } + } + + files := map[string]bool{} + for id, call := range toolCalls { + if !success[id] { + continue + } + switch call.name { + case "write_file", "edit_file": + if path, _ := call.params["path"].(string); path != "" { + files[path] = true + } + case "apply_diff": + if diff, _ := call.params["diff"].(string); diff != "" { + for _, path := range pathsFromDiff(diff) { + files[path] = true + } + } + } + } + + out := make([]string, 0, len(files)) + for path := range files { + out = append(out, path) + } + sort.Strings(out) + return out +} + +func pathsFromDiff(diff string) []string { + files := map[string]bool{} + for _, line := range strings.Split(diff, "\n") { + if !strings.HasPrefix(line, "+++ ") { + continue + } + path := strings.TrimSpace(strings.TrimPrefix(line, "+++ ")) + path = strings.TrimPrefix(path, "b/") + if path != "" && path != "/dev/null" { + files[path] = true + } + } + out := make([]string, 0, len(files)) + for path := range files { + out = append(out, path) + } + sort.Strings(out) + return out +} + +func gitBranch(workDir string) string { + if strings.TrimSpace(workDir) == "" { + return "" + } + cmd := exec.Command("git", "-C", workDir, "rev-parse", "--abbrev-ref", "HEAD") + out, err := cmd.Output() + if err != nil { + return "" + } + branch := strings.TrimSpace(string(out)) + if branch == "HEAD" { + return "" + } + return branch +} + +func fromTaskID(in Input) string { + if in.FromTaskID != "" { + return in.FromTaskID + } + if in.SessionID != "" { + return in.SessionID + } + return "local-session" +} + +func packetID(now time.Time) string { + return fmt.Sprintf("handoff-%d", now.UnixNano()) +} + +func tailMessages(messages []llm.Message, max int) []llm.Message { + if len(messages) <= max { + return messages + } + return messages[len(messages)-max:] +} + +func cleanFacts(facts []Fact) []Fact { + out := make([]Fact, 0, len(facts)) + for _, fact := range facts { + key := strings.TrimSpace(fact.Key) + value := strings.TrimSpace(fact.Value) + if key == "" || value == "" { + continue + } + out = append(out, Fact{Key: key, Value: value}) + } + return out +} + +func cleanStrings(values []string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + out = append(out, value) + } + } + return out +} + +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "...(truncated)" +} + +// HandoffPathForTranscript returns the default sidecar path for a transcript. +func HandoffPathForTranscript(transcriptPath string) string { + if transcriptPath == "" { + return "" + } + ext := filepath.Ext(transcriptPath) + if ext == "" { + return transcriptPath + ".handoff.json" + } + return strings.TrimSuffix(transcriptPath, ext) + ".handoff.json" +} diff --git a/packages/harness/handoff/generate_test.go b/packages/harness/handoff/generate_test.go new file mode 100644 index 000000000..310150360 --- /dev/null +++ b/packages/harness/handoff/generate_test.go @@ -0,0 +1,135 @@ +package handoff + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/workspace/harness/llm" + "github.com/workspace/harness/transcript" +) + +type errorProvider struct{} + +func (errorProvider) SendMessage(context.Context, []llm.Message, []llm.ToolDefinition) (*llm.Response, error) { + return nil, errors.New("summary unavailable") +} + +func TestGenerateHappyPathPlatformJSONKeys(t *testing.T) { + log := transcript.NewLog() + log.Append(transcript.EventToolCall, 1, map[string]any{ + "id": "write-1", + "name": "write_file", + "params": map[string]any{"path": "src/app.go"}, + }) + log.Append(transcript.EventToolResult, 1, map[string]any{ + "call_id": "write-1", + "is_error": false, + "content": "Created src/app.go", + }) + + provider := llm.NewMockProvider(&llm.Response{Content: `{ + "summary": "Implemented the requested change.", + "facts": [{"key": "tests", "value": "go test passed"}], + "openQuestions": ["None"], + "suggestedActions": ["Open a PR"] + }`}) + + packet := Generate(context.Background(), provider, Input{ + MissionID: "mission-1", + FromTaskID: "task-1", + TaskPrompt: "implement feature", + TerminalStatus: StatusSuccess, + StopReason: "complete", + TurnsUsed: 2, + Transcript: log, + TranscriptPath: "runs/session.json", + Now: time.UnixMilli(1710000000000), + }) + + if packet.Summary != "Implemented the requested change." { + t.Fatalf("summary = %q", packet.Summary) + } + if len(packet.Facts) != 1 || packet.Facts[0].Key != "tests" { + t.Fatalf("facts = %#v", packet.Facts) + } + if len(packet.ArtifactRefs) != 2 { + t.Fatalf("artifact refs = %#v", packet.ArtifactRefs) + } + + data, err := json.Marshal(packet) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var keys map[string]json.RawMessage + if err := json.Unmarshal(data, &keys); err != nil { + t.Fatalf("unmarshal keys: %v", err) + } + for _, key := range []string{ + "id", "missionId", "fromTaskId", "toTaskId", "summary", "facts", + "openQuestions", "artifactRefs", "suggestedActions", "version", "createdAt", + } { + if _, ok := keys[key]; !ok { + t.Fatalf("missing platform JSON key %q in %s", key, data) + } + } +} + +func TestGenerateLLMFailureUsesMechanicalFallback(t *testing.T) { + packet := Generate(context.Background(), errorProvider{}, Input{ + TaskPrompt: "fix flaky test", + TerminalStatus: StatusError, + StopReason: "error", + TurnsUsed: 3, + Now: time.UnixMilli(1710000000000), + }) + + if !strings.Contains(packet.Summary, "fix flaky test") { + t.Fatalf("fallback summary did not include task prompt: %q", packet.Summary) + } + if !strings.Contains(packet.Summary, "status error after 3 turns") { + t.Fatalf("fallback summary did not include status/turns: %q", packet.Summary) + } + if !hasFact(packet.Facts, "handoffGeneration") { + t.Fatalf("fallback facts missing handoffGeneration: %#v", packet.Facts) + } +} + +func TestGenerateMalformedJSONUsesMechanicalFallback(t *testing.T) { + provider := llm.NewMockProvider(&llm.Response{Content: "not json"}) + packet := Generate(context.Background(), provider, Input{ + TaskPrompt: "continue work", + TerminalStatus: StatusIncomplete, + StopReason: "max_turns", + TurnsUsed: 5, + Now: time.UnixMilli(1710000000000), + }) + + if packet.Summary == "not json" { + t.Fatal("malformed LLM content was used as summary") + } + if !hasFactValue(packet.Facts, "terminalStatus", "incomplete") { + t.Fatalf("fallback facts = %#v", packet.Facts) + } +} + +func hasFact(facts []Fact, key string) bool { + for _, fact := range facts { + if fact.Key == key { + return true + } + } + return false +} + +func hasFactValue(facts []Fact, key, value string) bool { + for _, fact := range facts { + if fact.Key == key && fact.Value == value { + return true + } + } + return false +} diff --git a/packages/harness/handoff/persist.go b/packages/harness/handoff/persist.go new file mode 100644 index 000000000..9fa1fdc78 --- /dev/null +++ b/packages/harness/handoff/persist.go @@ -0,0 +1,27 @@ +package handoff + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// Save writes the handoff packet as indented JSON. +func Save(path string, packet Packet) error { + if path == "" { + return fmt.Errorf("handoff: path is required") + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("handoff: create directory: %w", err) + } + data, err := json.MarshalIndent(packet, "", " ") + if err != nil { + return fmt.Errorf("handoff: marshal packet: %w", err) + } + data = append(data, '\n') + if err := os.WriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("handoff: write packet: %w", err) + } + return nil +} diff --git a/packages/harness/handoff/types.go b/packages/harness/handoff/types.go new file mode 100644 index 000000000..0a310b722 --- /dev/null +++ b/packages/harness/handoff/types.go @@ -0,0 +1,41 @@ +// Package handoff generates SAM platform-compatible session handoff packets. +package handoff + +// Packet mirrors packages/shared/src/types/mission.ts HandoffPacket. +type Packet struct { + ID string `json:"id"` + MissionID string `json:"missionId"` + FromTaskID string `json:"fromTaskId"` + ToTaskID *string `json:"toTaskId"` + Summary string `json:"summary"` + Facts []Fact `json:"facts"` + OpenQuestions []string `json:"openQuestions"` + ArtifactRefs []ArtifactRef `json:"artifactRefs"` + SuggestedActions []string `json:"suggestedActions"` + Version int `json:"version"` + CreatedAt int64 `json:"createdAt"` +} + +// Fact mirrors packages/shared/src/types/mission.ts HandoffFact. +type Fact struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// ArtifactRef mirrors packages/shared/src/types/mission.ts HandoffArtifactRef. +type ArtifactRef struct { + Type ArtifactType `json:"type"` + Ref string `json:"ref"` + Description string `json:"description,omitempty"` +} + +// ArtifactType is the platform handoff artifact type union. +type ArtifactType string + +const ( + ArtifactPR ArtifactType = "pr" + ArtifactFile ArtifactType = "file" + ArtifactLibraryFile ArtifactType = "library_file" + ArtifactBranch ArtifactType = "branch" + ArtifactURL ArtifactType = "url" +) diff --git a/tasks/archive/2026-07-04-harness-session-handoff.md b/tasks/archive/2026-07-04-harness-session-handoff.md new file mode 100644 index 000000000..8c1ff6dd7 --- /dev/null +++ b/tasks/archive/2026-07-04-harness-session-handoff.md @@ -0,0 +1,34 @@ +# Harness Session Handoff Artifact + +## Problem + +The native Go harness ends sessions with a transcript and optional session-store state, but does not emit the SAM platform `HandoffPacket` shape. Downstream agents and platform code need a structured handoff containing a summary, facts, open questions, artifact references, and suggested actions. + +## Research Findings + +- Canonical platform shape is in `packages/shared/src/types/mission.ts` on `origin/main`: `HandoffPacket` includes `id`, `missionId`, `fromTaskId`, `toTaskId`, `summary`, `facts`, `openQuestions`, `artifactRefs`, `suggestedActions`, `version`, and `createdAt`. +- Harness terminal states are produced in `packages/harness/agent/loop.go` as `complete`, `max_turns`, `cancelled`, and `error`. +- The LLM abstraction is `packages/harness/llm.Provider`, which is mock-testable. +- The transcript records tool calls and results; successful `write_file`, `edit_file`, and `apply_diff` calls can be used to derive mechanical file artifact references without widening tool APIs. +- CLI transcript writing happens after `agent.Run` in `packages/harness/cmd/harness/main.go`, so handoff generation should happen in the loop while persistence can happen on the CLI exit path. +- Design reference `.library/harness-engineering-course-integration-opportunities.md` recommends targeting the existing platform handoff shape rather than inventing a parallel one. + +## Checklist + +- [x] Add `packages/harness/handoff` with platform-shaped Go structs and JSON field names. +- [x] Add single-call LLM generation that parses structured JSON defensively. +- [x] Add mechanical fallback for LLM failure or malformed JSON. +- [x] Add mechanical artifact refs for transcript path, modified files, and git branch. +- [x] Hook terminal session exit in `agent/loop.go` with minimal footprint. +- [x] Persist handoff JSON on CLI exit path and print its location. +- [x] Add mock-provider tests for happy path, LLM failure, malformed JSON, and terminal statuses. +- [x] Fix ACP mock-provider streaming suppression exposed by the full Go test gate. +- [x] Run Go tests for `packages/harness`. + +## Acceptance Criteria + +- Session end can produce a JSON handoff packet with platform-compatible field names. +- Handoff generation never changes the session exit status. +- Failed or malformed LLM handoff calls degrade to mechanical fallback. +- CLI prints the written handoff artifact path. +- PR targets `harness/develop` and remains open for orchestrator review.