Return structured single-agent artifacts
This commit is contained in:
parent
b91fc337fd
commit
0040b940a4
@ -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,
|
||||
|
||||
@ -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" {
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user