fix(collaboration): harden delegated task isolation (#1486)

* feat(collaboration): isolate delegated artifacts and child runs

Isolate delegated inputs and outputs behind secure artifact exchange lifecycles. Scope Agent Link tasks by tenant and root agent, and enforce delegation spawn-tree boundaries. Add process-wide child-run admission and preserve logical media paths across native, MCP, and sandbox execution.

* fix(collaboration): harden delegated task isolation

Enforce tenant and root-agent task scope across migrations and stores. Add exactly-once async completion delivery, delegated sandbox boundaries, and confined artifact and media recovery across runtime surfaces.

* fix(collaboration): recover interrupted async tasks

* fix(collaboration): normalize persisted child-run status

---------

Co-authored-by: ntduc <ntduc@cpp.ai.vn>
This commit is contained in:
Duc Nguyenandntduc authored and GitHub committed 2026-07-30 14:17:40 +07:00
1 parent 496b7ffce6
commit bb7712a9ff
186 files changed
+18858 -1233

No files matched your search

@@ -0,0 +1,141 @@
package agent
import (
"context"
"encoding/json"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
)
func isExistingRealDirectory(path string) bool {
if path == "" {
return false
}
info, err := os.Lstat(path)
return err == nil && info.IsDir() && info.Mode()&os.ModeSymlink == 0
}
func validateDelegationArtifactWorkspace(delegationID, inputsPath, outputsPath string) bool {
parsedID, err := uuid.Parse(delegationID)
if err != nil || parsedID == uuid.Nil ||
!isExistingRealDirectory(inputsPath) ||
!isExistingRealDirectory(outputsPath) {
return false
}
inputsReal, err := filepath.EvalSymlinks(filepath.Clean(inputsPath))
if err != nil {
return false
}
outputsReal, err := filepath.EvalSymlinks(filepath.Clean(outputsPath))
if err != nil {
return false
}
exchangeRoot := filepath.Dir(inputsReal)
return filepath.Base(inputsReal) == "inputs" &&
filepath.Base(outputsReal) == "outputs" &&
filepath.Dir(outputsReal) == exchangeRoot &&
filepath.Base(exchangeRoot) == parsedID.String() &&
filepath.Base(filepath.Dir(exchangeRoot)) == "delegations" &&
filepath.Base(filepath.Dir(filepath.Dir(exchangeRoot))) == "collaboration"
}
func delegationArtifactTextRedactor(req *RunRequest) tracing.TextRedactor {
if req == nil || req.RunKind != "delegate" ||
req.DelegateInputsPath == "" || req.DelegateOutputsPath == "" {
return nil
}
type replacement struct {
value string
alias string
}
var replacements []replacement
seen := make(map[string]struct{})
add := func(value, alias string) {
for _, variant := range []string{
value,
filepath.ToSlash(value),
strings.Trim(strconv.Quote(value), `"`),
} {
if variant == "" {
continue
}
if _, exists := seen[variant]; exists {
continue
}
seen[variant] = struct{}{}
replacements = append(replacements, replacement{value: variant, alias: alias})
}
}
add(req.DelegateInputsPath, "inputs")
add(req.DelegateOutputsPath, "outputs")
if root := filepath.Dir(req.DelegateInputsPath); root == filepath.Dir(req.DelegateOutputsPath) {
add(root, "delegation exchange")
}
sort.Slice(replacements, func(i, j int) bool {
return len(replacements[i].value) > len(replacements[j].value)
})
pairs := make([]string, 0, len(replacements)*2)
for _, replacement := range replacements {
pairs = append(pairs, replacement.value, replacement.alias)
}
replacer := strings.NewReplacer(pairs...)
return replacer.Replace
}
func withDelegationArtifactTextRedactor(ctx context.Context, req *RunRequest) context.Context {
return tracing.WithTextRedactor(ctx, delegationArtifactTextRedactor(req))
}
func redactDelegationAgentEvent(req *RunRequest, event AgentEvent) AgentEvent {
redactor := delegationArtifactTextRedactor(req)
event.Payload = tracing.RedactValueWith(redactor, event.Payload)
return event
}
func redactDelegationMessage(req *RunRequest, message providers.Message) providers.Message {
redactor := delegationArtifactTextRedactor(req)
if redactor == nil {
return message
}
message.Content = redactor(message.Content)
message.Thinking = redactor(message.Thinking)
if len(message.RawAssistantContent) > 0 {
message.RawAssistantContent = json.RawMessage(redactor(string(message.RawAssistantContent)))
}
for i := range message.ToolCalls {
if arguments, ok := tracing.RedactValueWith(redactor, message.ToolCalls[i].Arguments).(map[string]any); ok {
message.ToolCalls[i].Arguments = arguments
}
message.ToolCalls[i].ParseError = redactor(message.ToolCalls[i].ParseError)
}
// Pre-publication paths are ephemeral and cannot be durable session media.
message.MediaRefs = nil
return message
}
func redactDelegationRunResult(req *RunRequest, result *RunResult) *RunResult {
if result == nil {
return nil
}
redactor := delegationArtifactTextRedactor(req)
if redactor == nil {
return result
}
result.Content = redactor(result.Content)
result.Thinking = redactor(result.Thinking)
result.LastBlockReply = redactor(result.LastBlockReply)
for i := range result.Deliverables {
result.Deliverables[i] = redactor(result.Deliverables[i])
}
// The outer artifact publisher is the only egress path.
result.Media = nil
return result
}
+62 -10
View File
@@ -27,6 +27,32 @@ type contextSetupResult struct {
// values needed by the agent loop and tool execution. Also runs input guard and message
// truncation. Returns error only if input guard blocks the message.
func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetupResult, error) {
isArtifactDelegation := req.RunKind == "delegate"
// A nested run must not inherit filesystem, Team, media, or delegation
// authority from its caller. Install explicit empty values before resolving
// this run's own scope.
ctx = store.WithRunContext(ctx, nil)
ctx = tools.WithToolWorkspace(ctx, "")
ctx = tools.WithToolTeamWorkspace(ctx, "")
ctx = tools.WithToolTeamRoot(ctx, "")
ctx = tools.WithToolTeamID(ctx, "")
ctx = tools.WithTeamTaskID(ctx, "")
ctx = tools.WithLeaderAgentID(ctx, "")
ctx = tools.WithTenantAllowedPaths(ctx, nil)
ctx = tools.WithWorkspaceChannel(ctx, "")
ctx = tools.WithWorkspaceChatID(ctx, "")
ctx = tools.WithDelegationID(ctx, "")
ctx = tools.WithDelegationArtifactInputs(ctx, "")
ctx = tools.WithRunKind(ctx, "")
ctx = tools.WithRunMediaPaths(ctx, nil)
ctx = tools.WithRunMediaNames(ctx, nil)
ctx = tools.WithMediaImages(ctx, nil)
ctx = tools.WithMediaImageRefs(ctx, nil)
ctx = tools.WithMediaDocRefs(ctx, nil)
ctx = tools.WithMediaAudioRefs(ctx, nil)
ctx = tools.WithMediaVideoRefs(ctx, nil)
// Inject agent UUID + key into context for tool routing
if l.agentUUID != uuid.Nil {
ctx = store.WithAgentID(ctx, l.agentUUID)
@@ -87,7 +113,7 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
ctx = tools.WithTenantToolSettings(ctx, l.tenantToolSettings)
}
// Inject tenant-specific allowed paths for filesystem tools.
if len(l.tenantAllowedPaths) > 0 {
if !isArtifactDelegation && len(l.tenantAllowedPaths) > 0 {
ctx = tools.WithTenantAllowedPaths(ctx, l.tenantAllowedPaths)
}
// Inject channel type into context for tools (e.g. message tool needs it for Zalo group routing)
@@ -150,6 +176,9 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
if req.DelegationID != "" {
ctx = tools.WithDelegationID(ctx, req.DelegationID)
}
if req.RunKind != "" {
ctx = tools.WithRunKind(ctx, req.RunKind)
}
// --- Per-user setup: file seeding + workspace resolution ---
// Uses userSetups sync.Map to track both concerns atomically per user.
@@ -163,7 +192,7 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
// Layer order: tenant → team → project (future) → user/chat
// Two entry modes: solo agent (base = l.workspace) or team context (base = l.dataDir).
// Result is always a single folder set via WithToolWorkspace.
if l.workspace != "" && req.UserID != "" {
if !isArtifactDelegation && l.workspace != "" && req.UserID != "" {
ws := setup.workspace
if ws == "" {
ws = l.workspace
@@ -197,12 +226,21 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
effectiveWorkspace = l.workspace
}
ctx = tools.WithToolWorkspace(ctx, effectiveWorkspace)
} else if l.workspace != "" {
} else if !isArtifactDelegation && l.workspace != "" {
ctx = tools.WithToolWorkspace(ctx, l.workspace)
}
if isArtifactDelegation {
if req.TeamWorkspace != "" ||
!validateDelegationArtifactWorkspace(req.DelegationID, req.DelegateInputsPath, req.DelegateOutputsPath) {
return contextSetupResult{}, fmt.Errorf("invalid delegation artifact workspace")
}
ctx = tools.WithDelegationArtifactInputs(ctx, req.DelegateInputsPath)
ctx = tools.WithToolWorkspace(ctx, req.DelegateOutputsPath)
}
// Team workspace: dispatched task overrides default workspace.
if req.TeamWorkspace != "" {
if !isArtifactDelegation && req.TeamWorkspace != "" {
if err := os.MkdirAll(req.TeamWorkspace, 0755); err != nil {
// See note above on loop_context user workspace fallback. A broken
// req.TeamWorkspace would otherwise become cmd.Dir and surface as
@@ -214,7 +252,7 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
ctx = tools.WithToolWorkspace(ctx, req.TeamWorkspace)
}
}
if req.TeamID != "" {
if !isArtifactDelegation && req.TeamID != "" {
ctx = tools.WithToolTeamID(ctx, req.TeamID)
// Team root for dispatched tasks: resolve the UserChatLayer-stripped root
// so the dispatched agent can still read peer-scoped files in the same team.
@@ -228,7 +266,7 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
ctx = tools.WithToolTeamRoot(ctx, teamRoot)
}
}
if req.LeaderAgentID != "" {
if !isArtifactDelegation && req.LeaderAgentID != "" {
ctx = tools.WithLeaderAgentID(ctx, req.LeaderAgentID)
}
@@ -237,14 +275,14 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
var resolvedTeamSettings json.RawMessage
// Dispatched tasks already have TeamWorkspace set but still need team settings
// for TeamIsolated flag. Fetch by explicit TeamID in that branch.
if req.TeamWorkspace != "" && req.TeamID != "" && l.teamStore != nil {
if !isArtifactDelegation && req.TeamWorkspace != "" && req.TeamID != "" && l.teamStore != nil {
if teamUUID, err := uuid.Parse(req.TeamID); err == nil {
if team, _ := l.teamStore.GetTeam(ctx, teamUUID); team != nil {
resolvedTeamSettings = team.Settings
}
}
}
if req.TeamWorkspace == "" && l.teamStore != nil && l.agentUUID != uuid.Nil {
if !isArtifactDelegation && req.TeamWorkspace == "" && l.teamStore != nil && l.agentUUID != uuid.Nil {
if team, _ := l.teamStore.GetTeamForAgent(ctx, l.agentUUID); team != nil {
resolvedTeamSettings = team.Settings
wsChat := req.ChatID
@@ -285,7 +323,16 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
}
// V3 workspace: resolve once, set immutable context.
{
if isArtifactDelegation {
ctx = workspace.WithContext(ctx, &workspace.WorkspaceContext{
ActivePath: req.DelegateOutputsPath,
Scope: workspace.ScopeDelegate,
MemoryScope: "user",
KGScope: "user",
OwnerID: req.UserID,
EnforcementLabel: workspace.DefaultEnforcementLabel(workspace.ScopeDelegate, false),
})
} else {
var teamIDPtr *string
if req.TeamID != "" {
teamIDPtr = &req.TeamID
@@ -386,6 +433,10 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
}
// Extract resolved credential user ID (set earlier via WithCredentialUserID, empty if not resolved).
credUserID := store.ExplicitCredentialUserIDFromContext(ctx)
tenantAllowedPaths := l.tenantAllowedPaths
if isArtifactDelegation {
tenantAllowedPaths = nil
}
rc := &store.RunContext{
AgentID: l.agentUUID,
AgentKey: l.id,
@@ -419,9 +470,10 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
WorkspaceChatID: effectiveWorkspaceChatID,
TeamIsolated: resolvedTeamSettings != nil && !tools.IsSharedWorkspace(resolvedTeamSettings),
TeamTaskID: req.TeamTaskID,
DelegationID: req.DelegationID,
LeaderAgentID: tools.LeaderAgentIDFromCtx(ctx),
AgentToolKey: l.id,
TenantAllowedPaths: l.tenantAllowedPaths,
TenantAllowedPaths: tenantAllowedPaths,
}
ctx = store.WithRunContext(ctx, rc)
@@ -0,0 +1,235 @@
package agent
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/internal/workspace"
)
func newArtifactRunRequest(t *testing.T, root string) *RunRequest {
t.Helper()
delegationID := uuid.NewString()
exchange := filepath.Join(root, "collaboration", "delegations", delegationID)
inputs := filepath.Join(exchange, "inputs")
outputs := filepath.Join(exchange, "outputs")
if err := os.MkdirAll(inputs, 0750); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(outputs, 0750); err != nil {
t.Fatal(err)
}
return &RunRequest{
SessionKey: "agent:worker:delegate:test",
RunKind: "delegate",
DelegationID: delegationID,
DelegateInputsPath: inputs,
DelegateOutputsPath: outputs,
}
}
func newArtifactTestLoop(root string) *Loop {
return NewLoop(LoopConfig{
ID: "worker",
AgentUUID: uuid.New(),
TenantID: store.MasterTenantID,
Workspace: filepath.Join(root, "agents", "worker"),
DataDir: root,
Sessions: &nopSessionStore{},
})
}
func TestInjectContext_UsesOnlyDelegationArtifactWorkspace(t *testing.T) {
root := t.TempDir()
req := newArtifactRunRequest(t, root)
parentCtx := tools.WithToolTeamWorkspace(context.Background(), filepath.Join(root, "teams", "stale"))
parentCtx = tools.WithToolTeamID(parentCtx, uuid.NewString())
parentCtx = tools.WithTenantAllowedPaths(parentCtx, []string{filepath.Join(root, "tenant-allowed")})
parentCtx = tools.WithDelegationArtifactInputs(parentCtx, filepath.Join(root, "stale-inputs"))
parentCtx = tools.WithRunMediaPaths(parentCtx, []string{"/stale/upload.txt"})
setup, err := newArtifactTestLoop(root).injectContext(parentCtx, req)
if err != nil {
t.Fatalf("injectContext: %v", err)
}
if got := tools.ToolWorkspaceFromCtx(setup.ctx); got != req.DelegateOutputsPath {
t.Fatalf("workspace = %q, want outputs %q", got, req.DelegateOutputsPath)
}
if got := tools.DelegationArtifactInputsFromCtx(setup.ctx); got != req.DelegateInputsPath {
t.Fatalf("inputs = %q, want %q", got, req.DelegateInputsPath)
}
if got := tools.ToolTeamWorkspaceFromCtx(setup.ctx); got != "" {
t.Fatalf("parent Team workspace survived: %q", got)
}
if got := tools.ToolTeamIDFromCtx(setup.ctx); got != "" {
t.Fatalf("parent Team ID survived: %q", got)
}
if got := tools.TenantAllowedPathsFromCtx(setup.ctx); len(got) != 0 {
t.Fatalf("parent tenant allowed paths survived: %v", got)
}
if got := tools.RunMediaPathsFromCtx(setup.ctx); len(got) != 0 {
t.Fatalf("parent media paths survived: %v", got)
}
wc := workspace.FromContext(setup.ctx)
if wc == nil || wc.Scope != workspace.ScopeDelegate || wc.ActivePath != req.DelegateOutputsPath {
t.Fatalf("workspace context = %#v", wc)
}
}
func TestInjectContext_DelegationSetupFailsClosed(t *testing.T) {
root := t.TempDir()
req := newArtifactRunRequest(t, root)
req.DelegateOutputsPath = filepath.Join(root, "missing")
if _, err := newArtifactTestLoop(root).injectContext(context.Background(), req); err == nil {
t.Fatal("missing artifact workspace fell back to personal workspace")
}
outside := filepath.Join(root, "outside")
if err := os.MkdirAll(outside, 0750); err != nil {
t.Fatal(err)
}
link := filepath.Join(root, "output-link")
if err := os.Symlink(outside, link); err != nil {
t.Fatal(err)
}
req.DelegateOutputsPath = link
if _, err := newArtifactTestLoop(root).injectContext(context.Background(), req); err == nil {
t.Fatal("symlink artifact workspace fell back to personal workspace")
}
req = newArtifactRunRequest(t, root)
req.DelegationID = uuid.NewString()
if _, err := newArtifactTestLoop(root).injectContext(context.Background(), req); err == nil {
t.Fatal("exchange path not bound to delegation ID was accepted")
}
}
func TestInjectContext_DelegatePreservesAuthorizationScope(t *testing.T) {
root := t.TempDir()
req := newArtifactRunRequest(t, root)
req.UserID = "group:telegram:-100123"
req.SenderID = "386246614"
req.Role = "viewer"
req.Channel = "delegate"
req.ChannelType = "telegram"
req.ChatID = "-100123"
req.PeerKind = "group"
req.WorkspaceChannel = "telegram-main"
req.WorkspaceChatID = "-100123"
setup, err := newArtifactTestLoop(root).injectContext(context.Background(), req)
if err != nil {
t.Fatalf("injectContext: %v", err)
}
if got := store.UserIDFromContext(setup.ctx); got != req.UserID {
t.Fatalf("UserID = %q, want %q", got, req.UserID)
}
if got := store.SenderIDFromContext(setup.ctx); got != req.SenderID {
t.Fatalf("SenderID = %q, want %q", got, req.SenderID)
}
if got := store.RoleFromContext(setup.ctx); got != req.Role {
t.Fatalf("Role = %q, want %q", got, req.Role)
}
}
func TestInjectContext_TeamDispatchKeepsSharedWorkspaceContract(t *testing.T) {
root := t.TempDir()
teamWorkspace := filepath.Join(root, "teams", "team-a", "shared")
if err := os.MkdirAll(teamWorkspace, 0750); err != nil {
t.Fatal(err)
}
req := &RunRequest{
SessionKey: "agent:worker:team:test",
TeamWorkspace: teamWorkspace,
TeamID: uuid.NewString(),
TeamTaskID: uuid.NewString(),
LeaderAgentID: uuid.NewString(),
}
setup, err := newArtifactTestLoop(root).injectContext(context.Background(), req)
if err != nil {
t.Fatalf("injectContext: %v", err)
}
if got := tools.ToolWorkspaceFromCtx(setup.ctx); got != teamWorkspace {
t.Fatalf("active workspace = %q, want Team workspace %q", got, teamWorkspace)
}
if got := tools.ToolTeamWorkspaceFromCtx(setup.ctx); got != teamWorkspace {
t.Fatalf("Team workspace = %q, want %q", got, teamWorkspace)
}
if got := tools.ToolTeamIDFromCtx(setup.ctx); got != req.TeamID {
t.Fatalf("Team ID = %q, want %q", got, req.TeamID)
}
if got := tools.TeamTaskIDFromCtx(setup.ctx); got != req.TeamTaskID {
t.Fatalf("Team task ID = %q, want %q", got, req.TeamTaskID)
}
if got := tools.DelegationArtifactInputsFromCtx(setup.ctx); got != "" {
t.Fatalf("Team dispatch gained artifact inputs: %q", got)
}
}
func TestDelegationArtifactRedactionCoversEventsSessionsAndResults(t *testing.T) {
req := newArtifactRunRequest(t, t.TempDir())
hostResult := filepath.Join(req.DelegateOutputsPath, "report.txt")
hostInput := filepath.Join(req.DelegateInputsPath, "source.txt")
event := redactDelegationAgentEvent(req, AgentEvent{
Payload: map[string]any{
"content": "created " + hostResult,
"media": []any{hostResult},
},
})
message := redactDelegationMessage(req, providers.Message{
Content: "read " + hostInput,
Thinking: "write " + hostResult,
MediaRefs: []providers.MediaRef{{Path: hostResult}},
ToolCalls: []providers.ToolCall{{
Arguments: map[string]any{"path": hostResult},
}},
RawAssistantContent: json.RawMessage(`{"path":` + mustJSONQuote(t, hostResult) + `}`),
})
result := redactDelegationRunResult(req, &RunResult{
Content: "done at " + hostResult,
Thinking: hostInput,
Deliverables: []string{hostResult},
Media: []MediaResult{{Path: hostResult}},
})
encoded, err := json.Marshal([]any{event, message, result})
if err != nil {
t.Fatal(err)
}
for _, hostRoot := range []string{
filepath.Dir(req.DelegateInputsPath),
req.DelegateInputsPath,
req.DelegateOutputsPath,
} {
if strings.Contains(string(encoded), hostRoot) {
t.Fatalf("redacted boundary leaked %q: %s", hostRoot, encoded)
}
}
if len(message.MediaRefs) != 0 || len(result.Media) != 0 {
t.Fatalf("ephemeral media survived: message=%#v result=%#v", message.MediaRefs, result.Media)
}
if !strings.Contains(string(encoded), "inputs/source.txt") ||
!strings.Contains(string(encoded), "outputs/report.txt") {
t.Fatalf("logical aliases missing: %s", encoded)
}
}
func mustJSONQuote(t *testing.T, value string) string {
t.Helper()
encoded, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
return string(encoded)
}
+10 -4
View File
@@ -14,7 +14,6 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// isUserFilePopulated checks if USER.md has been filled with actual user data
@@ -48,7 +47,7 @@ func (l *Loop) finalizeRun(
// BEFORE sanitize strips them. Covers cases where a tool returned its
// artifact via the ForLLM MEDIA: prefix but the agent relayed it as plain
// text (e.g. PDF from exec/weasyprint, TTS mp3 paths the LLM quotes back).
if extracted := extractMediaFromContent(rs.finalContent, tools.ToolWorkspaceFromCtx(ctx)); len(extracted) > 0 {
if extracted := extractMediaFromContent(rs.finalContent, l.mediaEgressRoots(ctx)); len(extracted) > 0 {
rs.mediaResults = append(rs.mediaResults, extracted...)
}
@@ -67,8 +66,12 @@ func (l *Loop) finalizeRun(
rs.finalContent += "\n\n---\n_" + i18n.T(locale, i18n.MsgSkillNudgePostscript) + "_"
}
// 7. Fallback for empty content
if rs.finalContent == "" {
// 7. Fallback only when there is no other deliverable output. Media-only
// runs must remain media-only instead of gaining a visible "..." caption.
hasDeliverableOutput := len(rs.mediaResults) > 0 ||
len(req.ForwardMedia) > 0 ||
req.ContentSuffix != ""
if rs.finalContent == "" && !hasDeliverableOutput {
if len(rs.asyncToolCalls) > 0 {
rs.finalContent = "..."
} else {
@@ -200,6 +203,9 @@ func (l *Loop) finalizeRun(
slog.Info("agent loop: NO_REPLY detected, suppressing delivery",
"agent", l.id, "session", req.SessionKey)
rs.finalContent = ""
if req.ContentSuffix != "" {
rs.finalContent = deduplicateMediaSuffix("", req.ContentSuffix)
}
}
// 9. Maybe summarize
+8
View File
@@ -48,6 +48,14 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s
tools.UserChatLayer(tools.SanitizePathSegment(userID), shared),
)
}
if tools.IsDelegationArtifactRun(ctx) {
promptWorkspace = "."
const artifactGuidance = "Delegation workspace: write outputs using ordinary relative paths in the current workspace. Read staged inputs only through inputs/... . Files are returned to the caller only after runtime validation and publication."
if extraSystemPrompt != "" {
extraSystemPrompt += "\n\n"
}
extraSystemPrompt += artifactGuidance
}
// Resolve context files once — also detect BOOTSTRAP.md presence.
// lightContext: skip loading context files, only inject ExtraSystemPrompt (heartbeat checklist).
+96 -17
View File
@@ -65,6 +65,9 @@ func (l *Loop) enrichInputMedia(ctx context.Context, req *RunRequest, messages [
var mediaRefs []providers.MediaRef
if len(req.Media) > 0 {
mediaRefs = l.persistMedia(req.SessionKey, req.Media, tools.ToolWorkspaceFromCtx(ctx))
if req.RunKind == "delegate" {
rehomeDelegatedMediaMessage(messages, req.Media, mediaRefs, tools.ToolWorkspaceFromCtx(ctx))
}
// Register persisted text uploads in vault (async, non-blocking).
if l.onTextUploaded != nil {
@@ -109,46 +112,55 @@ func (l *Loop) enrichInputMedia(ctx context.Context, req *RunRequest, messages [
ctx = l.loadHistoricalImagesForTool(ctx, mediaRefs, messages)
}
// 2b. Collect document MediaRefs (historical + current) for read_document tool.
// 2b. Collect image MediaRefs (historical + current) for exact media_id
// resolution by read_image and create_image.
if imageRefs := collectRefsByKind(messages, mediaRefs, "image"); len(imageRefs) > 0 {
ctx = tools.WithMediaImageRefs(ctx, imageRefs)
}
// 2c. Collect document MediaRefs (historical + current) for read_document tool.
if docRefs := collectRefsByKind(messages, mediaRefs, "document"); len(docRefs) > 0 {
ctx = tools.WithMediaDocRefs(ctx, docRefs)
// Enrich the last user message with persisted file paths so skills can access
// documents via exec (e.g. pypdf). Only for current-turn refs (just persisted).
l.enrichDocumentPaths(messages, mediaRefs)
// Enrich the last user message with exact IDs and logical workspace paths.
// Only current-turn refs are paired with current-turn tags.
l.enrichDocumentPaths(messages, mediaRefs, tools.ToolWorkspaceFromCtx(ctx))
}
// 2c. Collect audio MediaRefs (historical + current) for read_audio tool.
// 2d. Collect audio MediaRefs (historical + current) for read_audio tool.
if audioRefs := collectRefsByKind(messages, mediaRefs, "audio"); len(audioRefs) > 0 {
ctx = tools.WithMediaAudioRefs(ctx, audioRefs)
l.enrichAudioIDs(messages, mediaRefs)
l.enrichAudioIDs(messages, mediaRefs, tools.ToolWorkspaceFromCtx(ctx))
}
// 2d. Collect video MediaRefs (historical + current) for read_video tool.
// 2e. Collect video MediaRefs (historical + current) for read_video tool.
if videoRefs := collectRefsByKind(messages, mediaRefs, "video"); len(videoRefs) > 0 {
ctx = tools.WithMediaVideoRefs(ctx, videoRefs)
l.enrichVideoIDs(messages, mediaRefs)
}
// 2e. Enrich <media:image> tags with persisted media IDs so the LLM
// 2f. Enrich <media:image> tags with persisted media IDs so the LLM
// knows images were received and stored (consistent with audio/video enrichment).
l.enrichImageIDs(messages, mediaRefs)
l.enrichImageIDs(messages, mediaRefs, tools.ToolWorkspaceFromCtx(ctx))
// 2e-ii. In file-ref mode, enrich ALL user messages' image tags with file paths.
// This enables read_image(path=...) for both current and historical images.
if deferToReadImageTool {
l.enrichImagePaths(messages)
}
// 2f-ii. Enrich ALL user messages' image tags with logical file paths.
// This upgrades legacy absolute paths and keeps current and historical media
// references safe regardless of whether images are also attached inline.
l.enrichImagePaths(messages, tools.ToolWorkspaceFromCtx(ctx))
// 2f. Collect all media file paths for team workspace auto-collect.
// 2g. Collect all media file paths for team workspace auto-collect.
// When the leader calls team_tasks(create), these paths are copied to the
// team workspace so members can access attached files.
if len(mediaRefs) > 0 && l.mediaStore != nil {
if len(mediaRefs) > 0 {
var mediaPaths []string
for _, ref := range mediaRefs {
// Prefer workspace-local path (.uploads/) over canonical .media/ path.
if ref.Path != "" {
mediaPaths = append(mediaPaths, ref.Path)
} else if p, err := l.mediaStore.LoadPath(ref.ID); err == nil {
} else if l.mediaStore != nil {
p, err := l.mediaStore.LoadPath(ref.ID)
if err != nil {
continue
}
mediaPaths = append(mediaPaths, p)
}
}
@@ -166,3 +178,70 @@ func (l *Loop) enrichInputMedia(ctx context.Context, req *RunRequest, messages [
return ctx, messages, mediaRefs
}
// rehomeDelegatedMediaMessage replaces caller-owned attachment paths with the
// delegatee-owned persisted copies and ensures every imported attachment has a
// media tag. The normal enrichment pass then adds the new IDs and local paths.
func rehomeDelegatedMediaMessage(
messages []providers.Message,
input []bus.MediaFile,
refs []providers.MediaRef,
workspace string,
) {
if len(messages) == 0 || len(refs) == 0 {
return
}
lastUser := -1
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role == "user" {
lastUser = i
break
}
}
if lastUser < 0 {
return
}
content := messages[lastUser].Content
for i := 0; i < len(input) && i < len(refs); i++ {
if input[i].Path != "" && refs[i].Path != "" {
replacement := logicalWorkspaceMediaPath(workspace, refs[i].Path)
if replacement == "" {
replacement = filepath.Base(refs[i].Path)
}
content = strings.ReplaceAll(content, input[i].Path, replacement)
}
}
tagCounts := map[string]int{
"image": strings.Count(content, "<media:image"),
"document": strings.Count(content, "<media:document"),
"audio": strings.Count(content, "<media:audio") + strings.Count(content, "<media:voice"),
"video": strings.Count(content, "<media:video"),
}
for _, ref := range refs {
tag := ""
switch ref.Kind {
case "image":
tag = "<media:image>"
case "document":
tag = "<media:document>"
case "audio":
tag = "<media:audio>"
case "video":
tag = "<media:video>"
}
if tag == "" {
continue
}
if tagCounts[ref.Kind] > 0 {
tagCounts[ref.Kind]--
continue
}
if content != "" && !strings.HasSuffix(content, "\n") {
content += "\n"
}
content += tag
}
messages[lastUser].Content = content
}
+60 -19
View File
@@ -1,9 +1,12 @@
package agent
import (
"context"
"os"
"path/filepath"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// parseMediaResult extracts a MediaResult from a tool result string containing "MEDIA:" prefix.
@@ -94,8 +97,7 @@ func confineToWorkspace(mediaPath, workspace string) (string, bool) {
path = filepath.Join(wsRoot, path)
}
cleaned := filepath.Clean(path)
info, err := os.Lstat(cleaned)
if err != nil || !info.Mode().IsRegular() {
if err := tools.ValidateRegularFileForRead(cleaned); err != nil {
return "", false
}
resolved, err := filepath.EvalSymlinks(cleaned)
@@ -109,18 +111,31 @@ func confineToWorkspace(mediaPath, workspace string) (string, bool) {
return cleaned, true
}
// confineToAnyRoot accepts mediaPath if it is contained by ANY of the allowed
// roots (each checked with the hardened confineToWorkspace). Used for the
// result.Media egress: a tool's media legitimately lives in the agent
// workspace, the team workspace, OR a tenant-allowed path — the same scopes the
// producing tools (create_*, send_file, delegate) validate against. A path
// outside every root (e.g. /etc/passwd from a prompt-injected path) is rejected,
// so the egress guard holds without dropping legitimate cross-workspace media.
// confineToAnyRoot accepts an absolute mediaPath if it is contained by any
// allowed root. Relative paths are intentionally anchored to the first root,
// which is always the active tool workspace; extra roots authorize explicit
// paths but never change the meaning of an ambiguous relative path.
func confineToAnyRoot(mediaPath string, roots []string) (string, bool) {
for _, root := range roots {
if len(roots) == 0 {
return "", false
}
if !filepath.IsAbs(mediaPath) {
return confineToWorkspace(mediaPath, roots[0])
}
for i, root := range roots {
if root == "" {
continue
}
// The active workspace (index 0) may itself be reached through an
// operator-managed symlink. Extra read roots are authorization
// boundaries and must be real directories, not partner-controlled
// symlink aliases to arbitrary locations.
if i > 0 {
if info, err := os.Lstat(root); err == nil &&
(info.Mode()&os.ModeSymlink != 0 || !info.IsDir()) {
continue
}
}
if cleaned, ok := confineToWorkspace(mediaPath, root); ok {
return cleaned, true
}
@@ -128,17 +143,43 @@ func confineToAnyRoot(mediaPath string, roots []string) (string, bool) {
return "", false
}
// mediaEgressRoots returns every read-authorized root that may supply outbound
// media. The active workspace stays first to preserve relative path semantics.
func (l *Loop) mediaEgressRoots(ctx context.Context) []string {
tenantAllowedPaths := tools.TenantAllowedPathsFromCtx(ctx)
candidates := make([]string, 0, 3+len(tenantAllowedPaths))
candidates = append(candidates,
tools.ToolWorkspaceFromCtx(ctx),
tools.ToolTeamWorkspaceFromCtx(ctx),
tools.ToolTeamRootFromCtx(ctx),
)
candidates = append(candidates, tenantAllowedPaths...)
seen := make(map[string]struct{}, len(candidates))
roots := make([]string, 0, len(candidates))
for _, root := range candidates {
if root == "" {
continue
}
cleaned := filepath.Clean(root)
if _, ok := seen[cleaned]; ok {
continue
}
seen[cleaned] = struct{}{}
roots = append(roots, cleaned)
}
return roots
}
// extractMediaFromContent scans text for MEDIA:<path> tokens the LLM may echo
// in its final response (e.g. when a tool returned the MEDIA: prefix as plain
// text instead of setting Result.Media). Relative paths are resolved against
// workspace. Called before sanitize strips the tokens so the attachments are
// still delivered.
// text instead of setting Result.Media). The first root is the active workspace
// used for relative paths; later roots authorize explicit absolute paths.
// Called before sanitize strips the tokens so the attachments are delivered.
//
// Security: only paths accepted by confineToWorkspace are emitted. An LLM cannot
// inject attachments pointing at /etc/passwd, a sibling tenant's workspace, or a
// hallucinated path — the extractor silently drops them.
func extractMediaFromContent(content, workspace string) []MediaResult {
if !strings.Contains(content, "MEDIA:") || workspace == "" {
// Security: only paths accepted by confineToAnyRoot are emitted.
func extractMediaFromContent(content string, roots []string) []MediaResult {
if !strings.Contains(content, "MEDIA:") || len(roots) == 0 {
return nil
}
matches := mediaPathPattern.FindAllString(content, -1)
@@ -158,7 +199,7 @@ func extractMediaFromContent(content, workspace string) []MediaResult {
if path == "" {
continue
}
cleaned, ok := confineToWorkspace(path, workspace)
cleaned, ok := confineToAnyRoot(path, roots)
if !ok {
continue
}
+79 -20
View File
@@ -1,9 +1,13 @@
package agent
import (
"context"
"os"
"path/filepath"
"slices"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// writeTempFile drops a zero-byte file at workspace/relPath, creating dirs.
@@ -31,6 +35,12 @@ func TestExtractMediaFromContent(t *testing.T) {
audioA := writeTempFile(t, ws, "a.mp3")
audioB := writeTempFile(t, ws, "b.mp3")
chartPath := writeTempFile(t, ws, "charts/q4.png")
partnerRaw := t.TempDir()
partner, err := filepath.EvalSymlinks(partnerRaw)
if err != nil {
t.Fatal(err)
}
partnerPath := writeTempFile(t, partner, "partner-result.png")
// Outside-workspace file: should be rejected by containment check.
outsideDir := t.TempDir()
@@ -38,6 +48,14 @@ func TestExtractMediaFromContent(t *testing.T) {
if err := os.WriteFile(outsidePath, nil, 0o644); err != nil {
t.Fatal(err)
}
hardlinkPath := filepath.Join(partner, "hardlink-result.png")
if err := os.Link(outsidePath, hardlinkPath); err != nil {
t.Skipf("hardlinks not supported: %v", err)
}
symlinkRoot := filepath.Join(t.TempDir(), "delegate")
if err := os.Symlink(outsideDir, symlinkRoot); err != nil {
t.Skipf("symlinks not supported: %v", err)
}
// Symlink inside workspace pointing to outside: must be rejected by
// EvalSymlinks-then-Rel containment. Covers the P0 ancestor-symlink
@@ -59,7 +77,7 @@ func TestExtractMediaFromContent(t *testing.T) {
tests := []struct {
name string
content string
workspace string
roots []string
wantPaths []string
}{
{
@@ -73,54 +91,75 @@ func TestExtractMediaFromContent(t *testing.T) {
{
name: "relative path resolved + exists",
content: "MEDIA:deliver/report.pdf",
workspace: ws,
roots: []string{ws},
wantPaths: []string{reportPath},
},
{
name: "multiple tokens deduped",
content: "First: MEDIA:a.mp3\nSecond: MEDIA:b.mp3\nAgain: MEDIA:a.mp3",
workspace: ws,
roots: []string{ws},
wantPaths: []string{audioA, audioB},
},
{
name: "markdown wrapped and punctuation stripped",
content: `![chart](MEDIA:charts/q4.png). See "MEDIA:deliver/report.pdf".`,
workspace: ws,
roots: []string{ws},
wantPaths: []string{chartPath, reportPath},
},
{
name: "hallucinated path dropped (file missing)",
content: "MEDIA:not-real.pdf",
workspace: ws,
name: "hallucinated path dropped (file missing)",
content: "MEDIA:not-real.pdf",
roots: []string{ws},
},
{
name: "path traversal escape blocked",
content: "MEDIA:../leak.pdf",
workspace: ws,
name: "path traversal escape blocked",
content: "MEDIA:../leak.pdf",
roots: []string{ws},
},
{
name: "absolute path outside workspace blocked",
content: "MEDIA:" + outsidePath,
workspace: ws,
name: "absolute path outside workspace blocked",
content: "MEDIA:" + outsidePath,
roots: []string{ws},
},
{
name: "absolute collaboration path allowed",
content: "MEDIA:" + partnerPath,
roots: []string{ws, partner},
wantPaths: []string{partnerPath},
},
{
name: "hardlink in collaboration path blocked",
content: "MEDIA:" + hardlinkPath,
roots: []string{ws, partner},
},
{
name: "symlinked collaboration root blocked",
content: "MEDIA:" + outsidePath,
roots: []string{ws, symlinkRoot},
},
{
name: "relative path does not search collaboration roots",
content: "MEDIA:partner-result.png",
roots: []string{ws, partner},
},
{
name: "absolute path with no workspace dropped",
content: "MEDIA:" + reportPath,
},
{
name: "symlink leaf rejected by Lstat",
content: "MEDIA:shortcut-to-leak.pdf",
workspace: ws,
name: "symlink leaf rejected by Lstat",
content: "MEDIA:shortcut-to-leak.pdf",
roots: []string{ws},
},
{
name: "ancestor symlink escape blocked (P0)",
content: "MEDIA:shared/victim.pdf",
workspace: ws,
name: "ancestor symlink escape blocked (P0)",
content: "MEDIA:shared/victim.pdf",
roots: []string{ws},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractMediaFromContent(tt.content, tt.workspace)
got := extractMediaFromContent(tt.content, tt.roots)
if len(got) != len(tt.wantPaths) {
t.Fatalf("count = %d, want %d; got=%+v", len(got), len(tt.wantPaths), got)
}
@@ -133,6 +172,25 @@ func TestExtractMediaFromContent(t *testing.T) {
}
}
func TestMediaEgressRoots(t *testing.T) {
ctx := tools.WithToolWorkspace(context.Background(), "/workspace/parent")
ctx = tools.WithToolTeamWorkspace(ctx, "/workspace/team/chat")
ctx = tools.WithToolTeamRoot(ctx, "/workspace/team")
ctx = tools.WithTenantAllowedPaths(ctx, []string{"/workspace/tenant-export"})
loop := NewLoop(LoopConfig{})
got := loop.mediaEgressRoots(ctx)
want := []string{
"/workspace/parent",
"/workspace/team/chat",
"/workspace/team",
"/workspace/tenant-export",
}
if !slices.Equal(got, want) {
t.Fatalf("mediaEgressRoots() = %v, want %v", got, want)
}
}
// TestConfineToWorkspace exercises the shared media path-containment boundary
// directly. It is the single guard that both feeders of MediaResult.Path rely
// on, so a regression here would reopen the outbound-exfiltration hole (H2).
@@ -180,6 +238,7 @@ func TestConfineToWorkspace(t *testing.T) {
{name: "absolute outside workspace rejected", path: outsidePath, workspace: ws, wantOK: false},
{name: "traversal escape rejected", path: "../secret.txt", workspace: ws, wantOK: false},
{name: "missing file rejected", path: "nope.pdf", workspace: ws, wantOK: false},
{name: "directory rejected", path: "deliver", workspace: ws, wantOK: false},
{name: "empty workspace rejected", path: insidePath, workspace: "", wantOK: false},
{name: "empty path rejected", path: "", workspace: ws, wantOK: false},
{name: "leaf symlink rejected", path: "shortcut.txt", workspace: ws, wantOK: false, symlink: true},
+2 -2
View File
@@ -45,7 +45,7 @@ func (l *Loop) runViaPipeline(ctx context.Context, req RunRequest) (*RunResult,
if err != nil {
return nil, err
}
return convertRunResult(pResult), nil
return redactDelegationRunResult(&req, convertRunResult(pResult)), nil
}
// buildPipelineDeps maps Loop fields + methods to PipelineDeps callbacks.
@@ -94,7 +94,7 @@ func (l *Loop) buildPipelineDeps(req *RunRequest, bridgeRS *runState) pipeline.P
ResolveContextWindow: l.resolveEffectiveContextWindow,
EmitEvent: func(event any) {
if ae, ok := event.(AgentEvent); ok {
l.emit(ae)
l.emit(redactDelegationAgentEvent(req, ae))
}
},
+32 -5
View File
@@ -40,7 +40,7 @@ func (l *Loop) pipelineCallbacks(req *RunRequest, bridgeRS *runState) pipelineCa
event.ChatID = req.ChatID
event.SessionKey = req.SessionKey
event.TenantID = l.tenantID
l.emit(event)
l.emit(redactDelegationAgentEvent(req, event))
}
return pipelineCallbackSet{
emitRun: emitRun,
@@ -188,7 +188,7 @@ func (l *Loop) makeEnrichMedia(req *RunRequest) func(ctx context.Context, state
if len(msgs) == 0 {
return nil
}
enrichedCtx, enrichedMsgs, _ := l.enrichInputMedia(ctx, req, msgs)
enrichedCtx, enrichedMsgs, currentRefs := l.enrichInputMedia(ctx, req, msgs)
// Propagate enriched context (media images/docs/audio/video refs for tools).
state.Ctx = enrichedCtx
// Update history with enriched messages (media tags, inline images).
@@ -196,6 +196,23 @@ func (l *Loop) makeEnrichMedia(req *RunRequest) func(ctx context.Context, state
if len(enrichedMsgs) > 1 {
state.Messages.SetHistory(enrichedMsgs[1:])
}
// Preserve the enriched current input for the first session checkpoint.
// Inline image bytes stay request-local; durable history stores only the
// logical tags plus absolute MediaRefs used internally for exact lookup.
if len(currentRefs) > 0 {
for i := len(enrichedMsgs) - 1; i >= 0; i-- {
if enrichedMsgs[i].Role != "user" {
continue
}
req.enrichedInputMessage = providers.Message{
Role: "user",
Content: enrichedMsgs[i].Content,
MediaRefs: append([]providers.MediaRef(nil), currentRefs...),
}
req.hasEnrichedInputMessage = true
break
}
}
return nil
}
}
@@ -365,6 +382,12 @@ func (l *Loop) makeCallLLM(req *RunRequest, emitRun func(AgentEvent)) func(ctx c
chatReq.Options[providers.OptPeerKind] = req.PeerKind
chatReq.Options[providers.OptLocalKey] = req.LocalKey
chatReq.Options[providers.OptWorkspace] = tools.ToolWorkspaceFromCtx(ctx)
if delegationID := tools.DelegationIDFromCtx(ctx); delegationID != "" {
chatReq.Options[providers.OptDelegationID] = delegationID
}
if inputs := tools.DelegationArtifactInputsFromCtx(ctx); inputs != "" {
chatReq.Options[providers.OptDelegationInputs] = inputs
}
// Pass the policy-filtered allowed tool set so the Claude CLI provider
// can restrict its native built-in tools (Bash, Edit, Write, Read,
// WebFetch, WebSearch) to what the agent's tool policy actually allows.
@@ -706,13 +729,17 @@ func (l *Loop) makeFlushMessages(req *RunRequest) func(ctx context.Context, sess
return func(ctx context.Context, sessionKey string, msgs []providers.Message) error {
if !userMsgFlushed && !req.HideInput && req.Message != "" {
userMsgFlushed = true
l.sessions.AddMessage(ctx, sessionKey, providers.Message{
inputMessage := providers.Message{
Role: "user",
Content: req.Message,
})
}
if req.hasEnrichedInputMessage {
inputMessage = req.enrichedInputMessage
}
l.sessions.AddMessage(ctx, sessionKey, redactDelegationMessage(req, inputMessage))
}
for _, msg := range msgs {
l.sessions.AddMessage(ctx, sessionKey, msg)
l.sessions.AddMessage(ctx, sessionKey, redactDelegationMessage(req, msg))
}
return nil
}
@@ -2,13 +2,17 @@ package agent
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/pipeline"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -25,6 +29,32 @@ func (p finalThinkingStreamProvider) ChatStream(context.Context, providers.ChatR
func (p finalThinkingStreamProvider) DefaultModel() string { return "test-model" }
func (p finalThinkingStreamProvider) Name() string { return "test-provider" }
type requestCaptureProvider struct {
request providers.ChatRequest
}
func (p *requestCaptureProvider) Chat(_ context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) {
p.request = req
return &providers.ChatResponse{Content: "ok"}, nil
}
func (p *requestCaptureProvider) ChatStream(_ context.Context, req providers.ChatRequest, _ func(providers.StreamChunk)) (*providers.ChatResponse, error) {
p.request = req
return &providers.ChatResponse{Content: "ok"}, nil
}
func (p *requestCaptureProvider) DefaultModel() string { return "test-model" }
func (p *requestCaptureProvider) Name() string { return "test-provider" }
type recordingSessionStore struct {
*nopSessionStore
added []providers.Message
}
func (s *recordingSessionStore) AddMessage(_ context.Context, _ string, msg providers.Message) {
s.added = append(s.added, msg)
}
func TestMakeCallLLM_StreamsFinalThinkingWhenNoThinkingChunkArrives(t *testing.T) {
col := &eventCollector{}
loop := &Loop{id: "test-agent", onEvent: col.onEvent}
@@ -58,6 +88,73 @@ func TestMakeCallLLM_StreamsFinalThinkingWhenNoThinkingChunkArrives(t *testing.T
}
}
func TestMakeCallLLMPropagatesDelegationArtifactBridgeOptions(t *testing.T) {
provider := &requestCaptureProvider{}
loop := &Loop{id: "target-agent", agentUUID: uuid.New()}
req := &RunRequest{RunID: "run-1", SessionKey: "delegate:session"}
state := &pipeline.RunState{Provider: provider, Model: "test-model"}
ctx := tools.WithToolWorkspace(context.Background(), "/runtime/outputs")
ctx = tools.WithDelegationID(ctx, "delegation-id")
ctx = tools.WithDelegationArtifactInputs(ctx, "/runtime/inputs")
if _, err := loop.makeCallLLM(req, func(AgentEvent) {})(ctx, state, providers.ChatRequest{}); err != nil {
t.Fatal(err)
}
if provider.request.Options[providers.OptDelegationID] != "delegation-id" ||
provider.request.Options[providers.OptDelegationInputs] != "/runtime/inputs" {
t.Fatalf("delegation options = %#v", provider.request.Options)
}
}
func TestEnrichedInputMediaPersistsForNextTurn(t *testing.T) {
sourceDir := t.TempDir()
sourcePath := filepath.Join(sourceDir, "photo.png")
if err := os.WriteFile(sourcePath, minimalPNG, 0o600); err != nil {
t.Fatal(err)
}
workspace := t.TempDir()
sessions := &recordingSessionStore{nopSessionStore: &nopSessionStore{}}
loop := &Loop{sessions: sessions}
req := &RunRequest{
SessionKey: "session-media",
Message: `<media:image url="attachment://photo.png">`,
Media: []bus.MediaFile{{
Path: sourcePath,
MimeType: "image/png",
Filename: "photo.png",
}},
}
state := &pipeline.RunState{Messages: pipeline.NewMessageBuffer(providers.Message{Role: "system"})}
state.Messages.SetHistory([]providers.Message{{Role: "user", Content: req.Message}})
ctx := tools.WithToolWorkspace(context.Background(), workspace)
if err := loop.makeEnrichMedia(req)(ctx, state); err != nil {
t.Fatal(err)
}
if err := loop.makeFlushMessages(req)(ctx, req.SessionKey, nil); err != nil {
t.Fatal(err)
}
if len(sessions.added) != 1 {
t.Fatalf("persisted messages = %d, want 1", len(sessions.added))
}
persisted := sessions.added[0]
if len(persisted.MediaRefs) != 1 {
t.Fatalf("persisted MediaRefs = %#v, want one image ref", persisted.MediaRefs)
}
if strings.Contains(persisted.Content, workspace) {
t.Fatalf("persisted content leaked workspace path: %q", persisted.Content)
}
if !strings.Contains(persisted.Content, `path=".uploads/`) {
t.Fatalf("persisted content lacks logical image path: %q", persisted.Content)
}
nextTurnRefs := collectRefsByKind([]providers.Message{persisted}, nil, "image")
if len(nextTurnRefs) != 1 || nextTurnRefs[0].ID != persisted.MediaRefs[0].ID {
t.Fatalf("next-turn refs = %#v, want persisted exact ID", nextTurnRefs)
}
}
func TestPromptCacheOptionsHelpers(t *testing.T) {
tenantID := uuid.MustParse("11111111-1111-1111-1111-111111111111")
agentID := uuid.MustParse("22222222-2222-2222-2222-222222222222")
@@ -384,6 +384,6 @@ func makeToolEmitRun(l *Loop, req *RunRequest) func(AgentEvent) {
event.Channel = req.Channel
event.ChatID = req.ChatID
event.TenantID = l.tenantID
l.emit(event)
l.emit(redactDelegationAgentEvent(req, event))
}
}
@@ -2,9 +2,11 @@ package agent
import (
"context"
"strings"
"sync"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/pipeline"
"github.com/nextlevelbuilder/goclaw/internal/providers"
@@ -72,6 +74,44 @@ func newTestLoopForToolCallbacks(onEvent func(AgentEvent)) *Loop {
}
}
func TestProcessToolResultSuppressesUnpublishedArtifactMediaBeforeEvent(t *testing.T) {
col := &eventCollector{}
loop := newTestLoopForToolCallbacks(col.onEvent)
req := &RunRequest{RunID: "run-artifact"}
ctx := tools.WithDelegationID(context.Background(), "delegation-1")
ctx = tools.WithDelegationArtifactInputs(ctx, "/runtime/delegations/delegation-1/inputs")
result := &tools.Result{
ForLLM: "created image\nMEDIA:/runtime/delegations/delegation-1/outputs/image.png",
Media: []bus.MediaFile{{Path: "/runtime/delegations/delegation-1/outputs/image.png"}},
}
toolMsg, _, _ := loop.processToolResult(
ctx,
&runState{},
req,
col.onEvent,
providers.ToolCall{ID: "tc-artifact", Name: "create_image"},
"create_image",
result,
false,
)
if strings.Contains(toolMsg.Content, "MEDIA:") || len(result.Media) != 0 {
t.Fatalf("unpublished media survived result policy: message=%q media=%#v", toolMsg.Content, result.Media)
}
events := col.filter(protocol.AgentEventToolResult)
if len(events) != 1 {
t.Fatalf("tool result events = %d, want 1", len(events))
}
payload, ok := events[0].Payload.(map[string]any)
if !ok {
t.Fatalf("event payload type = %T", events[0].Payload)
}
if value, _ := payload["result"].(string); strings.Contains(value, "MEDIA:") {
t.Fatalf("event leaked unpublished media marker: %q", value)
}
}
// TestMakeExecuteToolCall_EmitsToolCallEvent verifies the sequential wrapper
// emits a tool.call event before running tool I/O.
func TestMakeExecuteToolCall_EmitsToolCallEvent(t *testing.T) {
+7 -5
View File
@@ -18,6 +18,7 @@ import (
func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
l.activeRuns.Add(1)
defer l.activeRuns.Add(-1)
ctx = withDelegationArtifactTextRedactor(ctx, &req)
// Per-run emit wrapper: enriches every AgentEvent with delegation + routing context.
emitRun := func(event AgentEvent) {
@@ -32,7 +33,7 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
event.ChatID = req.ChatID
event.SessionKey = req.SessionKey
event.TenantID = store.TenantIDFromContext(ctx)
l.emit(event)
l.emit(redactDelegationAgentEvent(&req, event))
}
emitRun(AgentEvent{
@@ -75,7 +76,7 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
UserID: req.UserID,
Channel: req.Channel,
Name: traceName,
InputPreview: truncateStr(req.Message, l.traceCollector.PreviewMaxLen()),
InputPreview: tracing.RedactText(ctx, truncateStr(req.Message, l.traceCollector.PreviewMaxLen())),
Status: store.TraceStatusRunning,
StartTime: now,
CreatedAt: now,
@@ -140,7 +141,7 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
l.emitAgentSpanEnd(safeCtx, agentSpanID, runStart, nil, context.Canceled)
}
l.traceCollector.FinishTrace(safeCtx, traceID, store.TraceStatusError,
"trace finalized by safety net (likely panic or goroutine leak)", "")
tracing.RedactText(safeCtx, "trace finalized by safety net (likely panic or goroutine leak)"), "")
}()
}
@@ -195,7 +196,7 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
traceCtx = context.WithoutCancel(ctx)
traceStatus = store.TraceStatusCancelled
}
l.traceCollector.FinishTrace(traceCtx, traceID, traceStatus, err.Error(), "")
l.traceCollector.FinishTrace(traceCtx, traceID, traceStatus, tracing.RedactText(traceCtx, err.Error()), "")
}
return nil, err
}
@@ -243,7 +244,8 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
if !isChildTrace && l.traceCollector != nil && traceID != uuid.Nil {
traceFinalized = true
if result != nil {
l.traceCollector.FinishTrace(ctx, traceID, store.TraceStatusCompleted, "", truncateStr(result.Content, l.traceCollector.PreviewMaxLen()))
l.traceCollector.FinishTrace(ctx, traceID, store.TraceStatusCompleted, "",
tracing.RedactText(ctx, truncateStr(result.Content, l.traceCollector.PreviewMaxLen())))
} else {
l.traceCollector.FinishTrace(ctx, traceID, store.TraceStatusCompleted, "", "")
}
+8 -5
View File
@@ -36,6 +36,12 @@ func (l *Loop) processToolResult(
hadBootstrap bool,
) (toolMsg providers.Message, warningMsgs []providers.Message, action toolResultAction) {
// Agent Link outputs are not valid outbound media until the runtime has
// validated and atomically published the delegation manifest. Apply this
// before logs, events, loop detection, or message construction so no
// pre-publication MEDIA marker escapes through observability surfaces.
tools.ApplyDelegationArtifactResultPolicy(ctx, result)
// Record for loop detection.
argsHash := rs.loopDetector.record(registryName, tc.Arguments)
rs.loopDetector.recordResult(argsHash, result.ForLLM)
@@ -85,6 +91,7 @@ func (l *Loop) processToolResult(
// Collect MEDIA: paths from tool results.
// Prefer result.Media (explicit) over ForLLM MEDIA: prefix (legacy) to avoid duplicates.
mediaRoots := l.mediaEgressRoots(ctx)
if len(result.Media) > 0 {
// Egress containment: a tool that sets result.Media[].Path to a path
// outside every allowed scope (e.g. /etc/passwd from a prompt-injected
@@ -94,10 +101,6 @@ func (l *Loop) processToolResult(
// to — agent workspace, team workspace, and tenant-allowed paths — so a
// cross-workspace file (e.g. a teammate-produced file in the shared team
// workspace, or a synchronous delegatee's output) is not wrongly dropped.
mediaRoots := append([]string{
tools.ToolWorkspaceFromCtx(ctx),
tools.ToolTeamWorkspaceFromCtx(ctx),
}, l.tenantAllowedPaths...)
for i, mf := range result.Media {
cleaned, ok := confineToAnyRoot(mf.Path, mediaRoots)
if !ok {
@@ -123,7 +126,7 @@ func (l *Loop) processToolResult(
// Telegram sendDocument). A malicious or buggy tool emitting
// MEDIA:/etc/passwd is dropped here — fixing every channel at the source
// rather than per-channel. Mirrors extractMediaFromContent containment.
if cleaned, ok := confineToWorkspace(mr.Path, tools.ToolWorkspaceFromCtx(ctx)); ok {
if cleaned, ok := confineToAnyRoot(mr.Path, mediaRoots); ok {
mr.Path = cleaned
rs.mediaResults = append(rs.mediaResults, *mr)
} else {
+6 -6
View File
@@ -152,7 +152,7 @@ func (l *Loop) emitLLMSpanStart(ctx context.Context, start time.Time, iteration
}
}
collector.EmitSpan(span)
collector.EmitSpan(tracing.RedactSpan(ctx, span))
return spanID
}
@@ -244,7 +244,7 @@ func (l *Loop) emitLLMSpanEnd(ctx context.Context, spanID uuid.UUID, start time.
updates["metadata"] = spanMetadata
}
collector.EmitSpanUpdate(spanID, traceID, updates)
collector.EmitSpanUpdate(spanID, traceID, tracing.RedactSpanUpdates(ctx, updates))
}
// ---------------------------------------------------------------------------
@@ -289,7 +289,7 @@ func (l *Loop) emitToolSpanStart(ctx context.Context, start time.Time, toolName,
span.TenantID = store.MasterTenantID
}
collector.EmitSpan(span)
collector.EmitSpan(tracing.RedactSpan(ctx, span))
return spanID
}
@@ -353,7 +353,7 @@ func (l *Loop) emitToolSpanEnd(ctx context.Context, spanID uuid.UUID, start time
}
}
collector.EmitSpanUpdate(spanID, traceID, updates)
collector.EmitSpanUpdate(spanID, traceID, tracing.RedactSpanUpdates(ctx, updates))
}
// ---------------------------------------------------------------------------
@@ -401,7 +401,7 @@ func (l *Loop) emitAgentSpanStart(ctx context.Context, agentSpanID uuid.UUID, st
span.TenantID = store.MasterTenantID
}
collector.EmitSpan(span)
collector.EmitSpan(tracing.RedactSpan(ctx, span))
}
// emitAgentSpanEnd finalizes the running root agent span with results.
@@ -433,7 +433,7 @@ func (l *Loop) emitAgentSpanEnd(ctx context.Context, agentSpanID uuid.UUID, star
// with child spans that directly report model usage.
}
collector.EmitSpanUpdate(agentSpanID, traceID, updates)
collector.EmitSpanUpdate(agentSpanID, traceID, tracing.RedactSpanUpdates(ctx, updates))
}
// previewLimitForVerbose returns the preview character limit based on verbose mode.
+13 -5
View File
@@ -666,11 +666,13 @@ type RunRequest struct {
OnTraceCreated func(traceID uuid.UUID)
// Delegation context (set when running as a delegate agent)
DelegationID string // delegation ID for event correlation
TeamID string // team ID (if delegation is team-scoped)
TeamTaskID string // team task ID (if delegation has an associated task)
ParentAgentID string // parent agent key that initiated the delegation
LeaderAgentID string // leader agent UUID for member memory read fallback
DelegationID string // delegation ID for event correlation
DelegateInputsPath string // runtime-only read-only staged inputs root
DelegateOutputsPath string // runtime-only writable exchange workspace
TeamID string // team ID (if delegation is team-scoped)
TeamTaskID string // team task ID (if delegation has an associated task)
ParentAgentID string // parent agent key that initiated the delegation
LeaderAgentID string // leader agent UUID for member memory read fallback
// Workspace scope propagation (set by delegation, read by workspace tools)
WorkspaceChannel string
@@ -678,6 +680,12 @@ type RunRequest struct {
// TeamWorkspace overrides the member agent's workspace with the team's workspace
// so file operations (read/write/image/audio) use the shared team directory.
TeamWorkspace string
// enrichedInputMessage is populated by the media stage and consumed by the
// first persistence checkpoint. It keeps current-turn MediaRefs and logical
// tags durable without storing inline image bytes.
enrichedInputMessage providers.Message
hasEnrichedInputMessage bool
}
// RunResult is the output of a completed agent run.
+162 -47
View File
@@ -8,6 +8,7 @@ import (
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/google/uuid"
@@ -299,13 +300,23 @@ func copyMediaFile(src, dst string) error {
return out.Close()
}
// enrichDocumentPaths updates the last user message to include persisted file paths
// in <media:document> tags. This allows skills (e.g. pdf skill via exec) to access
// the file directly, matching how Claude Code skills work with file paths.
func (l *Loop) enrichDocumentPaths(messages []providers.Message, refs []providers.MediaRef) {
// enrichDocumentPaths updates document tags with exact media IDs and
// workspace-relative paths. It upgrades historical tags and pairs current refs
// with the last user message. Paths outside the active workspace are omitted.
func (l *Loop) enrichDocumentPaths(messages []providers.Message, refs []providers.MediaRef, workspace string) {
if len(messages) == 0 {
return
}
// Upgrade historical tags as they re-enter the prompt. Current-turn refs
// are handled below because they are not yet attached to message history.
for i := range messages {
if messages[i].Role != "user" || len(messages[i].MediaRefs) == 0 {
continue
}
messages[i].Content = l.enrichDocumentTagContent(messages[i].Content, messages[i].MediaRefs, workspace)
}
lastIdx := -1
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role == "user" {
@@ -316,41 +327,62 @@ func (l *Loop) enrichDocumentPaths(messages []providers.Message, refs []provider
if lastIdx < 0 {
return
}
messages[lastIdx].Content = l.enrichDocumentTagContent(messages[lastIdx].Content, refs, workspace)
}
content := messages[lastIdx].Content
func (l *Loop) enrichDocumentTagContent(content string, refs []providers.MediaRef, workspace string) string {
for _, ref := range refs {
if ref.Kind != "document" {
continue
}
p := ref.Path
if p == "" && l.mediaStore != nil {
var err error
p, err = l.mediaStore.LoadPath(ref.ID)
if err != nil {
continue
if loaded, err := l.mediaStore.LoadPath(ref.ID); err == nil {
p = loaded
}
}
if p == "" {
logical := logicalWorkspaceMediaPath(workspace, p)
updateTag := func(tag string) string {
tag = setTagAttr(tag, "id", ref.ID)
if logical == "" {
return removeTagAttr(tag, "path")
}
return setTagAttr(tag, "path", logical)
}
// Prefer a tag already carrying the exact media ID. This also upgrades
// legacy absolute paths without relying on attribute order.
var replaced bool
content, replaced = replaceFirstMediaTag(content, "<media:document", func(tag string) bool {
return tagHasAttrValue(tag, "id", ref.ID)
}, updateTag)
if replaced {
continue
}
pathAttr := fmt.Sprintf(" path=%q", p)
// Match first <media:document> without a path — covers bare, named, and file= variants.
// Fallback: pair the next tag without an ID with this persisted ref.
content, _ = replaceFirstMediaTag(content, "<media:document", func(tag string) bool {
return !tagHasAttr(tag, "path")
}, func(tag string) string {
return appendTagAttrs(tag, pathAttr)
})
return !tagHasAttr(tag, "id")
}, updateTag)
}
messages[lastIdx].Content = content
return content
}
// enrichAudioIDs updates the last user message to embed persisted media IDs
// in <media:audio> and <media:voice> tags so the LLM can reference them.
func (l *Loop) enrichAudioIDs(messages []providers.Message, refs []providers.MediaRef) {
// enrichAudioIDs updates audio/voice tags with exact media IDs and logical
// workspace paths. Historical tags are upgraded when they re-enter the prompt.
func (l *Loop) enrichAudioIDs(messages []providers.Message, refs []providers.MediaRef, workspace string) {
if len(messages) == 0 {
return
}
for i := range messages {
if messages[i].Role != "user" || len(messages[i].MediaRefs) == 0 {
continue
}
messages[i].Content = l.enrichAudioTagContent(messages[i].Content, messages[i].MediaRefs, workspace)
}
lastIdx := -1
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role == "user" {
@@ -361,31 +393,55 @@ func (l *Loop) enrichAudioIDs(messages []providers.Message, refs []providers.Med
if lastIdx < 0 {
return
}
messages[lastIdx].Content = l.enrichAudioTagContent(messages[lastIdx].Content, refs, workspace)
}
content := messages[lastIdx].Content
func (l *Loop) enrichAudioTagContent(content string, refs []providers.MediaRef, workspace string) string {
for _, ref := range refs {
if ref.Kind != "audio" {
continue
}
idAttr := fmt.Sprintf(" id=%q", ref.ID)
p := ref.Path
if p == "" && l.mediaStore != nil {
if loaded, err := l.mediaStore.LoadPath(ref.ID); err == nil {
p = loaded
}
}
logical := logicalWorkspaceMediaPath(workspace, p)
updateTag := func(tag string) string {
tag = setTagAttr(tag, "id", ref.ID)
if logical == "" {
return removeTagAttr(tag, "path")
}
return setTagAttr(tag, "path", logical)
}
// Upgrade a historical tag already carrying this exact ID first.
var replaced bool
content, replaced = replaceFirstMediaTag(content, "<media:audio", func(tag string) bool {
return !tagHasAttr(tag, "id")
}, func(tag string) string {
return appendTagAttrs(tag, idAttr)
})
return tagHasAttrValue(tag, "id", ref.ID)
}, updateTag)
if replaced {
continue
}
content, replaced = replaceFirstMediaTag(content, "<media:voice", func(tag string) bool {
return tagHasAttrValue(tag, "id", ref.ID)
}, updateTag)
if replaced {
continue
}
content, _ = replaceFirstMediaTag(content, "<media:voice", func(tag string) bool {
// Pair a current ref with the next unowned audio/voice tag.
content, replaced = replaceFirstMediaTag(content, "<media:audio", func(tag string) bool {
return !tagHasAttr(tag, "id")
}, func(tag string) string {
return appendTagAttrs(tag, idAttr)
})
}, updateTag)
if !replaced {
content, _ = replaceFirstMediaTag(content, "<media:voice", func(tag string) bool {
return !tagHasAttr(tag, "id")
}, updateTag)
}
}
messages[lastIdx].Content = content
return content
}
// enrichVideoIDs updates the last user message to embed persisted media IDs
@@ -422,11 +478,11 @@ func (l *Loop) enrichVideoIDs(messages []providers.Message, refs []providers.Med
}
// enrichImageIDs updates the last user message to embed persisted media IDs
// and file paths in <media:image> tags so the LLM knows images were received
// and stored. The path attribute allows tools called via MCP bridge (e.g.
// and logical workspace paths in <media:image> tags so the LLM knows images
// were received and stored. The path attribute allows tools called via MCP bridge (e.g.
// claude-cli) to access images via read_image(path=...) even though the
// bridge context does not carry WithMediaImages.
func (l *Loop) enrichImageIDs(messages []providers.Message, refs []providers.MediaRef) {
func (l *Loop) enrichImageIDs(messages []providers.Message, refs []providers.MediaRef, workspace string) {
if len(messages) == 0 {
return
}
@@ -448,8 +504,8 @@ func (l *Loop) enrichImageIDs(messages []providers.Message, refs []providers.Med
}
idAttr := fmt.Sprintf(" id=%q", ref.ID)
pathAttr := ""
if ref.Path != "" {
pathAttr = fmt.Sprintf(" path=%q", ref.Path)
if logical := logicalWorkspaceMediaPath(workspace, ref.Path); logical != "" {
pathAttr = fmt.Sprintf(" path=%q", logical)
}
content, _ = replaceFirstMediaTag(content, "<media:image", func(tag string) bool {
@@ -465,15 +521,12 @@ func (l *Loop) enrichImageIDs(messages []providers.Message, refs []providers.Med
messages[lastIdx].Content = content
}
// enrichImagePaths updates ALL user messages to include persisted file paths
// enrichImagePaths updates ALL user messages to include logical workspace paths
// in <media:image> tags. This enables the LLM to call read_image(path=...)
// to analyze images without inline base64 (saving context tokens).
// Unlike enrichImageIDs (last user message only), this enriches ALL messages
// so historical images from prior turns are also accessible via file path.
func (l *Loop) enrichImagePaths(messages []providers.Message) {
if l.mediaStore == nil {
return
}
func (l *Loop) enrichImagePaths(messages []providers.Message, workspace string) {
for i := range messages {
if messages[i].Role != "user" || len(messages[i].MediaRefs) == 0 {
continue
@@ -485,7 +538,7 @@ func (l *Loop) enrichImagePaths(messages []providers.Message) {
continue
}
p := ref.Path
if p == "" {
if p == "" && l.mediaStore != nil {
var err error
p, err = l.mediaStore.LoadPath(ref.ID)
if err != nil {
@@ -495,14 +548,26 @@ func (l *Loop) enrichImagePaths(messages []providers.Message) {
if p == "" {
continue
}
pathAttr := fmt.Sprintf(" path=%q", p)
logical := logicalWorkspaceMediaPath(workspace, p)
if logical == "" {
var stripped bool
content, stripped = replaceFirstMediaTag(content, "<media:image", func(tag string) bool {
return tagHasAttrValue(tag, "id", ref.ID) && tagHasAttr(tag, "path")
}, func(tag string) string {
return removeTagAttr(tag, "path")
})
changed = changed || stripped
continue
}
// Prefer tags that already carry the matching media ID.
// Prefer tags that already carry the matching media ID. Replacing an
// existing path also upgrades legacy messages that persisted absolute
// host paths before logical media paths became the prompt contract.
var replaced bool
content, replaced = replaceFirstMediaTag(content, "<media:image", func(tag string) bool {
return tagHasAttrValue(tag, "id", ref.ID) && !tagHasAttr(tag, "path")
return tagHasAttrValue(tag, "id", ref.ID)
}, func(tag string) string {
return appendTagAttrs(tag, pathAttr)
return setTagAttr(tag, "path", logical)
})
if replaced {
changed = true
@@ -513,7 +578,7 @@ func (l *Loop) enrichImagePaths(messages []providers.Message) {
content, replaced = replaceFirstMediaTag(content, "<media:image", func(tag string) bool {
return !tagHasAttr(tag, "id")
}, func(tag string) string {
return appendTagAttrs(tag, fmt.Sprintf(` id=%q`, ref.ID), pathAttr)
return appendTagAttrs(tag, fmt.Sprintf(` id=%q`, ref.ID), fmt.Sprintf(` path=%q`, logical))
})
if replaced {
changed = true
@@ -525,6 +590,26 @@ func (l *Loop) enrichImagePaths(messages []providers.Message) {
}
}
// logicalWorkspaceMediaPath converts a persisted host path into the stable path
// contract exposed to models. Paths outside the active workspace are omitted.
func logicalWorkspaceMediaPath(workspace, mediaPath string) string {
if workspace == "" || mediaPath == "" {
return ""
}
if !filepath.IsAbs(mediaPath) {
clean := filepath.Clean(mediaPath)
if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return ""
}
return filepath.ToSlash(clean)
}
rel, err := filepath.Rel(filepath.Clean(workspace), filepath.Clean(mediaPath))
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return ""
}
return filepath.ToSlash(rel)
}
// mediaKindFromMime returns the media kind ("image", "video", "audio", "document")
// based on MIME type prefix.
func mediaKindFromMime(mime string) string {
@@ -580,6 +665,36 @@ func appendTagAttrs(tag string, attrs ...string) string {
return strings.TrimSuffix(tag, ">") + strings.Join(attrs, "") + ">"
}
func setTagAttr(tag, attr, value string) string {
prefix := " " + attr + `="`
start := strings.Index(tag, prefix)
if start < 0 {
return appendTagAttrs(tag, fmt.Sprintf(` %s=%q`, attr, value))
}
valueStart := start + len(prefix)
valueEnd := strings.IndexByte(tag[valueStart:], '"')
if valueEnd < 0 {
return tag
}
valueEnd += valueStart
quoted := strconv.Quote(value)
return tag[:valueStart] + quoted[1:len(quoted)-1] + tag[valueEnd:]
}
func removeTagAttr(tag, attr string) string {
prefix := " " + attr + `="`
start := strings.Index(tag, prefix)
if start < 0 {
return tag
}
valueStart := start + len(prefix)
valueEnd := strings.IndexByte(tag[valueStart:], '"')
if valueEnd < 0 {
return tag
}
return tag[:start] + tag[valueStart+valueEnd+1:]
}
// maxMediaReloadMessages is the default number of recent messages with image MediaRefs
// to reload for LLM vision context.
const maxMediaReloadMessages = 5
+216 -31
View File
@@ -1,13 +1,16 @@
package agent
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/media"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// TestEnrichImageIDs_BareTag verifies enrichment of a bare <media:image> tag
@@ -20,15 +23,62 @@ func TestEnrichImageIDs_BareTag(t *testing.T) {
refs := []providers.MediaRef{{ID: "img-1", Kind: "image", Path: "/tmp/a.jpg"}}
var loop Loop
loop.enrichImageIDs(messages, refs)
loop.enrichImageIDs(messages, refs, "/tmp")
got := messages[0].Content
want := `check <media:image id="img-1" path="/tmp/a.jpg">`
want := `check <media:image id="img-1" path="a.jpg">`
if got != want {
t.Fatalf("bare tag enrichment:\n got %q\nwant %q", got, want)
}
}
func TestEnrichInputMedia_DelegateRehomesCallerAttachment(t *testing.T) {
callerWorkspace := t.TempDir()
delegateWorkspace := t.TempDir()
source := filepath.Join(callerWorkspace, "reference.jpg")
if err := os.WriteFile(source, []byte("reference"), 0644); err != nil {
t.Fatal(err)
}
req := &RunRequest{
SessionKey: "delegate:parent:cppai-media:test",
RunKind: "delegate",
Media: []bus.MediaFile{{
Path: source,
MimeType: "image/jpeg",
Filename: "reference.jpg",
}},
}
messages := []providers.Message{{
Role: "user",
Content: "Use this exact reference: " + source,
}}
ctx := tools.WithToolWorkspace(context.Background(), delegateWorkspace)
loop := Loop{id: "cppai-media"}
_, gotMessages, refs := loop.enrichInputMedia(ctx, req, messages)
if len(refs) != 1 {
t.Fatalf("refs = %#v, want one delegate-owned attachment", refs)
}
if !strings.HasPrefix(refs[0].Path, filepath.Join(delegateWorkspace, ".uploads")+string(filepath.Separator)) {
t.Fatalf("persisted path = %q, want under delegate workspace %q", refs[0].Path, delegateWorkspace)
}
if strings.Contains(gotMessages[0].Content, source) {
t.Fatalf("delegate message still exposes caller path: %q", gotMessages[0].Content)
}
logicalPath := logicalWorkspaceMediaPath(delegateWorkspace, refs[0].Path)
if logicalPath == "" || !strings.Contains(gotMessages[0].Content, logicalPath) {
t.Fatalf("delegate message = %q, want logical copied path %q", gotMessages[0].Content, logicalPath)
}
if strings.Contains(gotMessages[0].Content, delegateWorkspace) {
t.Fatalf("delegate message leaked delegate workspace: %q", gotMessages[0].Content)
}
if !strings.Contains(gotMessages[0].Content, `<media:image`) {
t.Fatalf("delegate message = %q, want explicit image attachment tag", gotMessages[0].Content)
}
}
func TestEnrichImageIDs_PreservesExistingTagAttributes(t *testing.T) {
messages := []providers.Message{{
Role: "user",
@@ -41,7 +91,7 @@ func TestEnrichImageIDs_PreservesExistingTagAttributes(t *testing.T) {
}}
var loop Loop
loop.enrichImageIDs(messages, refs)
loop.enrichImageIDs(messages, refs, "/tmp")
got := messages[0].Content
if !strings.Contains(got, `url="https://cdn.discordapp.com/attachments/1/2/photo.jpg"`) {
@@ -50,7 +100,7 @@ func TestEnrichImageIDs_PreservesExistingTagAttributes(t *testing.T) {
if !strings.Contains(got, `id="image-1"`) {
t.Fatalf("expected id attribute to be added, got %q", got)
}
if !strings.Contains(got, `path="/tmp/photo.jpg"`) {
if !strings.Contains(got, `path="photo.jpg"`) {
t.Fatalf("expected path attribute to be added, got %q", got)
}
}
@@ -66,7 +116,7 @@ func TestEnrichImageIDs_SkipsAlreadyEnriched(t *testing.T) {
refs := []providers.MediaRef{{ID: "new-id", Kind: "image", Path: "/new/path.jpg"}}
var loop Loop
loop.enrichImageIDs(messages, refs)
loop.enrichImageIDs(messages, refs, "/new")
if messages[0].Content != original {
t.Fatalf("already-enriched tag should not be modified:\n got %q\nwant %q", messages[0].Content, original)
@@ -111,9 +161,9 @@ func testMediaStore(t *testing.T) *media.Store {
return s
}
// TestEnrichImagePaths_NoDoubleEnrich verifies that historical messages with
// url+id+path are not re-enriched on subsequent turns.
func TestEnrichImagePaths_NoDoubleEnrich(t *testing.T) {
// TestEnrichImagePaths_UpgradesLegacyAbsolutePath verifies that historical
// host paths are replaced by the current logical workspace contract.
func TestEnrichImagePaths_UpgradesLegacyAbsolutePath(t *testing.T) {
original := `<media:image url="https://cdn.example.com/photo.jpg" id="img-1" path="/workspace/.uploads/img-1.jpg">`
messages := []providers.Message{{
Role: "user",
@@ -126,10 +176,69 @@ func TestEnrichImagePaths_NoDoubleEnrich(t *testing.T) {
}}
loop := Loop{mediaStore: testMediaStore(t)}
loop.enrichImagePaths(messages)
loop.enrichImagePaths(messages, "/workspace")
if messages[0].Content != original {
t.Fatalf("double-enrichment detected:\n got %q\nwant %q", messages[0].Content, original)
want := `<media:image url="https://cdn.example.com/photo.jpg" id="img-1" path=".uploads/img-1.jpg">`
if messages[0].Content != want {
t.Fatalf("legacy path was not upgraded:\n got %q\nwant %q", messages[0].Content, want)
}
}
func TestEnrichImagePathsEscapesLogicalPathAttribute(t *testing.T) {
workspace := t.TempDir()
mediaPath := filepath.Join(workspace, ".uploads", `photo"quoted.jpg`)
messages := []providers.Message{{
Role: "user",
Content: `<media:image id="img-1" path="/legacy/photo.jpg">`,
MediaRefs: []providers.MediaRef{{
ID: "img-1",
Kind: "image",
Path: mediaPath,
}},
}}
var loop Loop
loop.enrichImagePaths(messages, workspace)
want := `<media:image id="img-1" path=".uploads/photo\"quoted.jpg">`
if messages[0].Content != want {
t.Fatalf("escaped path = %q, want %q", messages[0].Content, want)
}
}
func TestLogicalWorkspaceMediaPathRejectsOutsideWorkspace(t *testing.T) {
workspace := filepath.Join(t.TempDir(), "agent")
inside := filepath.Join(workspace, ".uploads", "photo.jpg")
outside := filepath.Join(filepath.Dir(workspace), "other-agent", ".uploads", "photo.jpg")
if got := logicalWorkspaceMediaPath(workspace, inside); got != ".uploads/photo.jpg" {
t.Fatalf("inside path = %q, want %q", got, ".uploads/photo.jpg")
}
if got := logicalWorkspaceMediaPath(workspace, outside); got != "" {
t.Fatalf("outside path = %q, want omitted", got)
}
if got := logicalWorkspaceMediaPath(workspace, "../escape.jpg"); got != "" {
t.Fatalf("traversal path = %q, want omitted", got)
}
}
func TestEnrichImagePathsStripsOutsideWorkspacePath(t *testing.T) {
messages := []providers.Message{{
Role: "user",
Content: `<media:image id="img-1" path="/workspace/other-agent/.uploads/photo.jpg">`,
MediaRefs: []providers.MediaRef{{
ID: "img-1",
Kind: "image",
Path: "/workspace/other-agent/.uploads/photo.jpg",
}},
}}
var loop Loop
loop.enrichImagePaths(messages, "/workspace/current-agent")
want := `<media:image id="img-1">`
if messages[0].Content != want {
t.Fatalf("outside workspace path was not stripped: got %q, want %q", messages[0].Content, want)
}
}
@@ -148,10 +257,10 @@ func TestEnrichImagePaths_AttributeOrderIndependence(t *testing.T) {
}}
loop := Loop{mediaStore: testMediaStore(t)}
loop.enrichImagePaths(messages)
loop.enrichImagePaths(messages, "/workspace")
got := messages[0].Content
if !strings.Contains(got, `path="/workspace/.uploads/img-1.jpg"`) {
if !strings.Contains(got, `path=".uploads/img-1.jpg"`) {
t.Fatalf("expected path to be added regardless of attribute order, got %q", got)
}
if !strings.Contains(got, `url="https://cdn.example.com/photo.jpg"`) {
@@ -173,9 +282,9 @@ func TestEnrichImageIDs_MultipleRefs(t *testing.T) {
}
var loop Loop
loop.enrichImageIDs(messages, refs)
loop.enrichImageIDs(messages, refs, "/tmp")
want := `first <media:image id="img-a" path="/tmp/a.jpg">` + "\n" + `second <media:image id="img-b" path="/tmp/b.jpg">`
want := `first <media:image id="img-a" path="a.jpg">` + "\n" + `second <media:image id="img-b" path="b.jpg">`
if messages[0].Content != want {
t.Fatalf("multi-ref alignment:\n got %q\nwant %q", messages[0].Content, want)
}
@@ -223,53 +332,129 @@ func TestEnrichImagePaths_MultipleRefsKeepTagAlignment(t *testing.T) {
var loop Loop
loop.mediaStore = mediaStore
loop.enrichImagePaths(messages)
loop.enrichImagePaths(messages, "/persisted")
want := `first <media:image id="` + idA + `" path="` + pathA + `">` +
"\n" + `second <media:image id="` + idB + `" path="` + pathB + `">`
want := `first <media:image id="` + idA + `" path="a.jpg">` +
"\n" + `second <media:image id="` + idB + `" path="b.jpg">`
if messages[0].Content != want {
t.Fatalf("enrichImagePaths() content = %q, want %q", messages[0].Content, want)
}
}
func TestEnrichDocumentPaths_MultipleRefs(t *testing.T) {
workspace := t.TempDir()
messages := []providers.Message{{
Role: "user",
Content: "first <media:document>\nsecond <media:document>",
}}
refs := []providers.MediaRef{
{ID: "doc-a", Kind: "document", Path: "/tmp/a.pdf"},
{ID: "doc-b", Kind: "document", Path: "/tmp/b.pdf"},
{ID: "doc-a", Kind: "document", Path: filepath.Join(workspace, ".uploads", "a.pdf")},
{ID: "doc-b", Kind: "document", Path: filepath.Join(workspace, ".uploads", "b.pdf")},
}
var loop Loop
loop.enrichDocumentPaths(messages, refs)
loop.enrichDocumentPaths(messages, refs, workspace)
want := `first <media:document path="/tmp/a.pdf">` + "\n" + `second <media:document path="/tmp/b.pdf">`
want := `first <media:document id="doc-a" path=".uploads/a.pdf">` + "\n" +
`second <media:document id="doc-b" path=".uploads/b.pdf">`
if messages[0].Content != want {
t.Fatalf("multi-ref alignment:\n got %q\nwant %q", messages[0].Content, want)
}
}
func TestEnrichDocumentPathsStripsOutsideWorkspacePath(t *testing.T) {
workspace := filepath.Join(t.TempDir(), "current-agent")
outside := filepath.Join(filepath.Dir(workspace), "other-agent", ".uploads", "secret.pdf")
messages := []providers.Message{{
Role: "user",
Content: `<media:document name="secret.pdf" path="/legacy/secret.pdf">`,
}}
refs := []providers.MediaRef{{
ID: "doc-1",
Kind: "document",
Path: outside,
}}
var loop Loop
loop.enrichDocumentPaths(messages, refs, workspace)
want := `<media:document name="secret.pdf" id="doc-1">`
if messages[0].Content != want {
t.Fatalf("outside workspace path was not stripped: got %q, want %q", messages[0].Content, want)
}
}
func TestEnrichDocumentPathsUpgradesHistoricalAbsolutePath(t *testing.T) {
workspace := t.TempDir()
docPath := filepath.Join(workspace, ".uploads", "history.pdf")
messages := []providers.Message{
{
Role: "user",
Content: `<media:document id="doc-old" path="/legacy/history.pdf">`,
MediaRefs: []providers.MediaRef{{
ID: "doc-old",
Kind: "document",
Path: docPath,
}},
},
{Role: "user", Content: "follow up"},
}
var loop Loop
loop.enrichDocumentPaths(messages, nil, workspace)
want := `<media:document id="doc-old" path=".uploads/history.pdf">`
if messages[0].Content != want {
t.Fatalf("historical path was not upgraded: got %q, want %q", messages[0].Content, want)
}
}
func TestEnrichAudioIDs_MultipleRefs(t *testing.T) {
workspace := t.TempDir()
messages := []providers.Message{{
Role: "user",
Content: "first <media:audio>\nsecond <media:audio>",
}}
refs := []providers.MediaRef{
{ID: "aud-a", Kind: "audio"},
{ID: "aud-b", Kind: "audio"},
{ID: "aud-a", Kind: "audio", Path: filepath.Join(workspace, ".uploads", "a.mp3")},
{ID: "aud-b", Kind: "audio", Path: filepath.Join(workspace, ".uploads", "b.mp3")},
}
var loop Loop
loop.enrichAudioIDs(messages, refs)
loop.enrichAudioIDs(messages, refs, workspace)
want := `first <media:audio id="aud-a">` + "\n" + `second <media:audio id="aud-b">`
want := `first <media:audio id="aud-a" path=".uploads/a.mp3">` + "\n" +
`second <media:audio id="aud-b" path=".uploads/b.mp3">`
if messages[0].Content != want {
t.Fatalf("multi-ref alignment:\n got %q\nwant %q", messages[0].Content, want)
}
}
func TestEnrichAudioIDsUpgradesHistoricalLogicalPath(t *testing.T) {
workspace := t.TempDir()
audioPath := filepath.Join(workspace, ".uploads", "history.mp3")
messages := []providers.Message{
{
Role: "user",
Content: `<media:voice id="aud-old" path="/legacy/history.mp3">`,
MediaRefs: []providers.MediaRef{{
ID: "aud-old",
Kind: "audio",
Path: audioPath,
}},
},
{Role: "user", Content: "follow up"},
}
var loop Loop
loop.enrichAudioIDs(messages, nil, workspace)
want := `<media:voice id="aud-old" path=".uploads/history.mp3">`
if messages[0].Content != want {
t.Fatalf("historical audio path was not upgraded: got %q, want %q", messages[0].Content, want)
}
}
func TestEnrichVideoIDs_MultipleRefs(t *testing.T) {
messages := []providers.Message{{
Role: "user",
@@ -301,9 +486,9 @@ func TestEnrichImageIDs_MoreRefsThanTags(t *testing.T) {
}
var loop Loop
loop.enrichImageIDs(messages, refs)
loop.enrichImageIDs(messages, refs, "/tmp")
want := `only one <media:image id="img-a" path="/tmp/a.jpg">`
want := `only one <media:image id="img-a" path="a.jpg">`
if messages[0].Content != want {
t.Fatalf("more refs than tags:\n got %q\nwant %q", messages[0].Content, want)
}
@@ -320,9 +505,9 @@ func TestEnrichImageIDs_MoreTagsThanRefs(t *testing.T) {
}
var loop Loop
loop.enrichImageIDs(messages, refs)
loop.enrichImageIDs(messages, refs, "/tmp")
want := `first <media:image id="img-a" path="/tmp/a.jpg">` + "\n" + `second <media:image>`
want := `first <media:image id="img-a" path="a.jpg">` + "\n" + `second <media:image>`
if messages[0].Content != want {
t.Fatalf("more tags than refs:\n got %q\nwant %q", messages[0].Content, want)
}
@@ -341,7 +526,7 @@ func TestEnrichAudioIDs_MixedAudioAndVoice(t *testing.T) {
}
var loop Loop
loop.enrichAudioIDs(messages, refs)
loop.enrichAudioIDs(messages, refs, "")
want := `hear this <media:audio id="aud-1">` + "\n" + `and this <media:voice id="aud-2">`
if messages[0].Content != want {
@@ -111,4 +111,10 @@ func TestBridgePromptBuilder_OrchestrationTargetContent(t *testing.T) {
if !strings.Contains(output, "Does work") {
t.Error("output should contain description")
}
if !strings.Contains(output, "Return generated files or media artifacts to the caller") {
t.Error("output should tell delegates to return media artifacts through the caller")
}
if !strings.Contains(output, "Do not ask the delegatee to send directly to your current chat") {
t.Error("output should prevent direct delivery attempts from the delegate channel")
}
}
-2
View File
@@ -98,8 +98,6 @@ type WorkspaceSectionData struct {
ActivePath string
Scope string
Enforced bool
ReadOnlyPaths []string
SharedPath *string
ContextFiles []string
EnforcementMsg string
}
+2 -2
View File
@@ -207,7 +207,7 @@ var coreToolSummaries = map[string]string{
"session_status": "Show session status (model, tokens, compaction count)",
"sessions_history": "Fetch message history for a session",
"sessions_send": "Send a message into another session",
"read_image": "Analyze images — call with path from <media:image> tags, or a direct HTTP/HTTPS URL via the 'url' parameter",
"read_image": "Analyze images — use the exact logical path from <media:image> tags; media_id and direct HTTP/HTTPS URLs are also supported",
"read_audio": "Analyze audio — call with media_id from <media:audio> tags",
"read_video": "Analyze video — call with media_id from <media:video> tags, or a direct HTTP/HTTPS URL via the 'url' parameter",
"create_video": "Generate videos from text descriptions using AI",
@@ -673,7 +673,7 @@ func buildToolingSection(toolNames []string, hasSandbox bool, shellDenyGroups ma
lines = append(lines,
"",
"### Media Files",
`When users send media (<media:image path="...">, <media:video id="...">, <media:audio id="...">, <media:document path="...">), use the corresponding read_* tool with the path/media_id. For archives (.zip, .tar.gz, etc.), use exec with the document path to inspect/extract the archive.`,
`When users send media (<media:image id="..." path="...">, <media:video id="...">, <media:audio id="...">, <media:document path="...">), use the corresponding read_* tool with the exact path/media_id from the tag. Never reconstruct or guess an absolute workspace path. For archives (.zip, .tar.gz, etc.), use exec with the document path to inspect/extract the archive.`,
"You have full vision/audio/video capabilities. NEVER say you cannot see images or files.",
)
}
+2
View File
@@ -771,6 +771,8 @@ func buildOrchestrationSection(data OrchestrationSectionData) []string {
lines = append(lines,
"",
"Use `delegate` with the agent_key of the target agent. Do NOT invent agent keys.",
"Return generated files or media artifacts to the caller through the delegate result.",
"Do not ask the delegatee to send directly to your current chat; delegated runs use an internal `delegate` channel and the caller handles final delivery.",
"",
)
return lines