Return structured single-agent artifacts

This commit is contained in:
Haitao Pan 2026-04-10 14:59:42 +08:00
parent b91fc337fd
commit 0040b940a4
3 changed files with 282 additions and 5 deletions

View File

@ -2,10 +2,15 @@ package acp
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"mime"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
@ -175,7 +180,7 @@ func (s *Server) runSingleAgentViaExternalProvider(
if len(result) == 0 {
result = response
}
return collector.apply(result), nil
return enrichSingleAgentResultArtifacts(collector.apply(result), forwardParams), nil
}
func resolveSingleAgentForwardEndpoint(provider syncedProvider) string {
@ -395,6 +400,180 @@ func (c *externalACPNotificationCollector) apply(result map[string]any) map[stri
return result
}
func enrichSingleAgentResultArtifacts(result map[string]any, requestParams map[string]any) map[string]any {
if result == nil {
result = map[string]any{}
}
remoteWorkingDirectory := firstNonEmptyString(
shared.StringArg(result, "remoteWorkingDirectory", ""),
shared.StringArg(asMap(result["remoteExecution"]), "remoteWorkingDirectory", ""),
shared.StringArg(result, "resolvedWorkingDirectory", ""),
shared.StringArg(result, "effectiveWorkingDirectory", ""),
shared.StringArg(requestParams, "workingDirectory", ""),
)
remoteWorkspaceRefKind := firstNonEmptyString(
shared.StringArg(result, "remoteWorkspaceRefKind", ""),
shared.StringArg(asMap(result["remoteExecution"]), "remoteWorkspaceRefKind", ""),
"remotePath",
)
if strings.TrimSpace(shared.StringArg(result, "resultSummary", "")) == "" {
if summary := firstNonEmptyString(
shared.StringArg(result, "summary", ""),
shared.StringArg(result, "output", ""),
shared.StringArg(result, "message", ""),
); summary != "" {
result["resultSummary"] = summary
}
}
result["remoteWorkingDirectory"] = remoteWorkingDirectory
result["remoteWorkspaceRefKind"] = remoteWorkspaceRefKind
result["remoteExecution"] = map[string]any{
"remoteWorkingDirectory": remoteWorkingDirectory,
"remoteWorkspaceRefKind": remoteWorkspaceRefKind,
"provider": shared.StringArg(result, "provider", ""),
"turnId": shared.StringArg(result, "turnId", ""),
}
if len(asSlice(result["artifacts"])) == 0 {
result["artifacts"] = collectInlineArtifactsPayload(requestParams, result)
}
return result
}
func collectInlineArtifactsPayload(requestParams, result map[string]any) []map[string]any {
roots := []string{
shared.StringArg(requestParams, "workingDirectory", ""),
shared.StringArg(result, "resolvedWorkingDirectory", ""),
shared.StringArg(result, "effectiveWorkingDirectory", ""),
}
seen := map[string]struct{}{}
for _, root := range roots {
root = strings.TrimSpace(root)
if root == "" {
continue
}
if _, ok := seen[root]; ok {
continue
}
seen[root] = struct{}{}
entries := buildArtifactsForRoot(root)
if len(entries) > 0 {
return entries
}
}
return []map[string]any{}
}
func buildArtifactsForRoot(root string) []map[string]any {
info, err := os.Stat(root)
if err != nil || !info.IsDir() {
return []map[string]any{}
}
type candidate struct {
absolute string
relative string
modTime time.Time
size int64
}
const maxFiles = 24
const maxInlineBytes = 2 * 1024 * 1024
ignoredDirs := map[string]struct{}{
".git": {}, ".dart_tool": {}, "build": {}, "node_modules": {},
}
candidates := make([]candidate, 0, maxFiles)
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return nil
}
if d.IsDir() {
if _, ignored := ignoredDirs[d.Name()]; ignored && path != root {
return filepath.SkipDir
}
return nil
}
info, err := d.Info()
if err != nil || info.Size() > maxInlineBytes {
return nil
}
relative, err := filepath.Rel(root, path)
if err != nil {
return nil
}
relative = filepath.ToSlash(strings.TrimSpace(relative))
if relative == "" || strings.HasPrefix(relative, "../") {
return nil
}
candidates = append(candidates, candidate{
absolute: path,
relative: relative,
modTime: info.ModTime(),
size: info.Size(),
})
return nil
})
sort.SliceStable(candidates, func(i, j int) bool {
return candidates[i].modTime.After(candidates[j].modTime)
})
if len(candidates) > maxFiles {
candidates = candidates[:maxFiles]
}
artifacts := make([]map[string]any, 0, len(candidates))
for _, item := range candidates {
content, err := os.ReadFile(item.absolute)
if err != nil {
continue
}
contentType := mime.TypeByExtension(filepath.Ext(item.absolute))
if strings.TrimSpace(contentType) == "" {
contentType = "application/octet-stream"
}
encoding := "base64"
payload := base64.StdEncoding.EncodeToString(content)
if isInlineTextArtifact(item.relative, contentType) {
encoding = "utf8"
payload = string(content)
}
artifacts = append(artifacts, map[string]any{
"relativePath": item.relative,
"label": filepath.Base(item.absolute),
"contentType": contentType,
"encoding": encoding,
"content": payload,
"sizeBytes": item.size,
})
}
return artifacts
}
func isInlineTextArtifact(path, contentType string) bool {
ext := strings.ToLower(filepath.Ext(path))
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(contentType)), "text/") {
return true
}
switch ext {
case ".md", ".markdown", ".txt", ".log", ".json", ".yaml", ".yml", ".csv", ".html", ".htm":
return true
default:
return false
}
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func asSlice(value any) []any {
if value == nil {
return nil
}
items, _ := value.([]any)
return items
}
func requestExternalACPWebSocket(
ctx context.Context,
endpoint *urlSpec,

View File

@ -170,6 +170,104 @@ func TestExecuteSessionTaskUsesSyncedExternalProvider(t *testing.T) {
}
}
func TestExecuteSessionTaskEnrichesExternalProviderResultWithArtifactsAndRemoteMetadata(t *testing.T) {
workingDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workingDir, "outputs"), 0o755); err != nil {
t.Fatalf("mkdir outputs: %v", err)
}
if err := os.WriteFile(
filepath.Join(workingDir, "outputs", "report.txt"),
[]byte("artifact-body"),
0o644,
); err != nil {
t.Fatalf("write artifact: %v", err)
}
externalServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/acp/rpc" {
http.NotFound(w, r)
return
}
defer r.Body.Close()
var request map[string]any
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatalf("decode request: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": request["id"],
"result": map[string]any{
"success": true,
"output": "external-provider-ok",
"turnId": "turn-external-artifacts",
"provider": "claude",
"mode": "single-agent",
"resolvedWorkingDirectory": "/remote/threads/task-42",
"resolvedWorkspaceRefKind": "remotePath",
},
})
}))
defer externalServer.Close()
server := NewServer()
server.syncProviders([]syncedProvider{
{
ProviderID: "claude",
Label: "Claude",
Endpoint: externalServer.URL,
AuthorizationHeader: "Bearer test",
Enabled: true,
},
})
response, rpcErr := server.executeSessionTask(task{
req: shared.RPCRequest{
Method: "session.start",
Params: map[string]any{
"sessionId": "session-external-artifacts",
"threadId": "thread-external-artifacts",
"taskPrompt": "hello from external provider",
"workingDirectory": workingDir,
"routing": map[string]any{
"routingMode": "explicit",
"explicitExecutionTarget": "singleAgent",
"explicitProviderId": "claude",
},
},
},
})
if rpcErr != nil {
t.Fatalf("expected success, got rpc error: %v", rpcErr)
}
if got := response["remoteWorkingDirectory"]; got != "/remote/threads/task-42" {
t.Fatalf("expected remoteWorkingDirectory to be preserved, got %#v", got)
}
if got := response["remoteWorkspaceRefKind"]; got != "remotePath" {
t.Fatalf("expected remoteWorkspaceRefKind remotePath, got %#v", got)
}
artifacts, ok := response["artifacts"].([]map[string]any)
if !ok || len(artifacts) == 0 {
t.Fatalf("expected enriched artifacts, got %#v", response["artifacts"])
}
artifact := artifacts[0]
if got := artifact["relativePath"]; got != "outputs/report.txt" {
t.Fatalf("expected relativePath outputs/report.txt, got %#v", got)
}
if got := artifact["content"]; got != "artifact-body" {
t.Fatalf("expected inline artifact content, got %#v", got)
}
if got := artifact["encoding"]; got != "utf8" {
t.Fatalf("expected utf8 artifact encoding, got %#v", got)
}
remoteExecution, ok := response["remoteExecution"].(map[string]any)
if !ok {
t.Fatalf("expected remoteExecution metadata, got %#v", response["remoteExecution"])
}
if got := remoteExecution["remoteWorkingDirectory"]; got != "/remote/threads/task-42" {
t.Fatalf("expected remoteExecution remoteWorkingDirectory, got %#v", got)
}
}
func TestRunSingleAgentUsesFrozenExternalProviderParams(t *testing.T) {
externalServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/acp/rpc" {

View File

@ -733,7 +733,7 @@ func (s *Server) runSingleAgent(
if _, exists := result["effectiveWorkingDirectory"]; !exists && effectiveWorkingDirectory != "" {
result["effectiveWorkingDirectory"] = effectiveWorkingDirectory
}
return taskResult{response: result}
return taskResult{response: enrichSingleAgentResultArtifacts(result, params)}
}
s.emitSessionUpdate(session, notify, turnID, map[string]any{
"type": "status",
@ -778,7 +778,7 @@ func (s *Server) runSingleAgent(
if _, exists := result["effectiveWorkingDirectory"]; !exists && effectiveWorkingDirectory != "" {
result["effectiveWorkingDirectory"] = effectiveWorkingDirectory
}
return taskResult{response: result}
return taskResult{response: enrichSingleAgentResultArtifacts(result, params)}
}
s.emitSessionUpdate(session, notify, turnID, map[string]any{
"type": "status",
@ -847,14 +847,14 @@ func (s *Server) runSingleAgent(
})
return taskResult{
response: map[string]any{
response: enrichSingleAgentResultArtifacts(map[string]any{
"success": true,
"output": output,
"turnId": turnID,
"mode": "single-agent",
"provider": provider,
"effectiveWorkingDirectory": effectiveWorkingDirectory,
},
}, params),
}
}