mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 16:17:25 +00:00
fix(delegation): inject dependency results + guard completed task reuse
- Add injectDependencyResults() to auto-inject blocked_by task results into delegation context, so delegatees receive prior results without needing to search for them (orchestrator-worker pattern) - Guard against spawning with completed/cancelled team_task_id to enforce one-task-per-delegation rule - Add cross-user scope guard in prepareDelegation() to prevent cross-group task leak (delegate/system channels bypass by design) - Track CompletedTaskIDs in DelegateArtifacts and include them in announce messages so lead agent knows not to reuse completed IDs - UI: reduce trace detail preview heights for better readability Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
32f9c99a71
commit
9b82128392
4 files changed
+106
-9
No files matched your search
@@ -86,8 +86,9 @@ type DelegateRunResult struct {
|
||||
// Used to accumulate artifacts from intermediate completions until the final
|
||||
// announce fires. New artifact types (files, voice, etc.) should be added here.
|
||||
type DelegateArtifacts struct {
|
||||
Media []string // file paths to forward (images, documents, audio, etc.)
|
||||
Results []DelegateResultSummary // result summaries from completed delegations
|
||||
Media []string // file paths to forward (images, documents, audio, etc.)
|
||||
Results []DelegateResultSummary // result summaries from completed delegations
|
||||
CompletedTaskIDs []string // team task IDs auto-completed by delegations (for announce context)
|
||||
}
|
||||
|
||||
// DelegateResultSummary is a compact representation of a delegation result
|
||||
@@ -180,6 +181,7 @@ func (dm *DelegateManager) Delegate(ctx context.Context, opts DelegateOpts) (*De
|
||||
dm.active.Delete(task.ID)
|
||||
}()
|
||||
|
||||
dm.injectDependencyResults(ctx, &opts)
|
||||
message := buildDelegateMessage(opts)
|
||||
dm.emitEvent("delegation.started", task)
|
||||
slog.Info("delegation started", "id", task.ID, "target", opts.TargetAgentKey, "mode", "sync")
|
||||
@@ -238,6 +240,7 @@ func (dm *DelegateManager) DelegateAsync(ctx context.Context, opts DelegateOpts)
|
||||
taskCtx = tracing.WithDelegateParentTraceID(taskCtx, parentTraceID)
|
||||
}
|
||||
|
||||
dm.injectDependencyResults(ctx, &opts)
|
||||
message := buildDelegateMessage(opts)
|
||||
dm.emitEvent("delegation.started", task)
|
||||
slog.Info("delegation started (async)", "id", task.ID, "target", opts.TargetAgentKey)
|
||||
@@ -286,6 +289,9 @@ func (dm *DelegateManager) DelegateAsync(ctx context.Context, opts DelegateOpts)
|
||||
Content: fmt.Sprintf("[failed] %s", runErr.Error()),
|
||||
}}
|
||||
}
|
||||
if task.TeamTaskID != uuid.Nil {
|
||||
arts.CompletedTaskIDs = []string{task.TeamTaskID.String()}
|
||||
}
|
||||
dm.accumulateArtifacts(task.SourceAgentID, arts)
|
||||
slog.Info("delegation announce suppressed (siblings still running)",
|
||||
"id", task.ID, "target", task.TargetAgentKey, "siblings", siblingCount)
|
||||
@@ -301,6 +307,9 @@ func (dm *DelegateManager) DelegateAsync(ctx context.Context, opts DelegateOpts)
|
||||
Deliverables: result.Deliverables,
|
||||
})
|
||||
}
|
||||
if task.TeamTaskID != uuid.Nil {
|
||||
artifacts.CompletedTaskIDs = append(artifacts.CompletedTaskIDs, task.TeamTaskID.String())
|
||||
}
|
||||
|
||||
announceMeta := map[string]string{
|
||||
"origin_channel": task.OriginChannel,
|
||||
@@ -417,13 +426,47 @@ func (dm *DelegateManager) prepareDelegation(ctx context.Context, opts DelegateO
|
||||
}
|
||||
return nil, nil, fmt.Errorf("team_task_id %s not found.%s", opts.TeamTaskID, hint)
|
||||
}
|
||||
|
||||
// Guard: scope task to current user_id (prevent cross-group task leak).
|
||||
// user_id is the GROUP composite ID (e.g. "group:telegram:-1003701523276"), NOT the sender.
|
||||
// Delegate/system channels skip this check — they operate cross-context by design.
|
||||
currentUserID := store.UserIDFromContext(ctx)
|
||||
channel := ToolChannelFromCtx(ctx)
|
||||
if channel != "delegate" && channel != "system" &&
|
||||
teamTask.UserID != "" && currentUserID != "" && teamTask.UserID != currentUserID {
|
||||
hint := ""
|
||||
if team != nil {
|
||||
hint = dm.pendingTasksHint(ctx, team.ID)
|
||||
}
|
||||
return nil, nil, fmt.Errorf(
|
||||
"team_task_id %s belongs to a different context. "+
|
||||
"Create a new task with team_tasks action=create.%s",
|
||||
opts.TeamTaskID, hint)
|
||||
}
|
||||
|
||||
// Guard: reject completed/cancelled tasks — enforce "one task per delegation".
|
||||
if teamTask.Status == store.TeamTaskStatusCompleted || teamTask.Status == "cancelled" {
|
||||
hint := ""
|
||||
if team != nil {
|
||||
hint = dm.pendingTasksHint(ctx, team.ID)
|
||||
}
|
||||
ownerLabel := "another agent"
|
||||
if teamTask.OwnerAgentKey != "" {
|
||||
ownerLabel = teamTask.OwnerAgentKey
|
||||
}
|
||||
return nil, nil, fmt.Errorf(
|
||||
"team_task_id %s is already %s (completed by %q). "+
|
||||
"Create a new task with team_tasks action=create for this delegation.%s",
|
||||
opts.TeamTaskID, teamTask.Status, ownerLabel, hint)
|
||||
}
|
||||
|
||||
if team != nil {
|
||||
if teamTask.TeamID != team.ID {
|
||||
return nil, nil, fmt.Errorf("team_task_id does not belong to your team")
|
||||
}
|
||||
userID := store.UserIDFromContext(ctx)
|
||||
channel := ToolChannelFromCtx(ctx)
|
||||
if err := checkTeamAccess(team.Settings, userID, channel); err != nil {
|
||||
ch := ToolChannelFromCtx(ctx)
|
||||
if err := checkTeamAccess(team.Settings, userID, ch); err != nil {
|
||||
return nil, nil, fmt.Errorf("team access denied: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -493,6 +536,47 @@ func (dm *DelegateManager) prepareDelegation(ctx context.Context, opts DelegateO
|
||||
return task, link, nil
|
||||
}
|
||||
|
||||
// injectDependencyResults fetches completed dependency results for a task's
|
||||
// blocked_by prerequisites and prepends them to opts.Context. This ensures the
|
||||
// delegate agent receives prior results without needing to search for them.
|
||||
func (dm *DelegateManager) injectDependencyResults(ctx context.Context, opts *DelegateOpts) {
|
||||
if dm.teamStore == nil || opts.TeamTaskID == uuid.Nil {
|
||||
return
|
||||
}
|
||||
teamTask, err := dm.teamStore.GetTask(ctx, opts.TeamTaskID)
|
||||
if err != nil || len(teamTask.BlockedBy) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var depContext []string
|
||||
for _, depID := range teamTask.BlockedBy {
|
||||
dep, err := dm.teamStore.GetTask(ctx, depID)
|
||||
if err != nil || dep.Result == nil || *dep.Result == "" {
|
||||
continue
|
||||
}
|
||||
result := *dep.Result
|
||||
if len(result) > 8000 {
|
||||
result = result[:8000] + "\n[...truncated]"
|
||||
}
|
||||
agentLabel := dep.OwnerAgentKey
|
||||
if agentLabel == "" {
|
||||
agentLabel = "unknown"
|
||||
}
|
||||
depContext = append(depContext, fmt.Sprintf(
|
||||
"--- Result from dependency task %q (id=%s, by %s) ---\n%s",
|
||||
dep.Subject, dep.ID, agentLabel, result))
|
||||
}
|
||||
|
||||
if len(depContext) > 0 {
|
||||
injected := strings.Join(depContext, "\n\n")
|
||||
if opts.Context != "" {
|
||||
opts.Context = injected + "\n\n" + opts.Context
|
||||
} else {
|
||||
opts.Context = injected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildDelegateMessage(opts DelegateOpts) string {
|
||||
if opts.Context != "" {
|
||||
return fmt.Sprintf("[Additional Context]\n%s\n\n[Task]\n%s", opts.Context, opts.Task)
|
||||
|
||||
@@ -42,6 +42,18 @@ func formatDelegateAnnounce(task *DelegationTask, artifacts *DelegateArtifacts,
|
||||
|
||||
msg := "[System Message] All team delegations completed.\n\n"
|
||||
|
||||
// List auto-completed task IDs so LLM doesn't reuse them
|
||||
if len(artifacts.CompletedTaskIDs) > 0 {
|
||||
msg += "Auto-completed team tasks: "
|
||||
for i, tid := range artifacts.CompletedTaskIDs {
|
||||
if i > 0 {
|
||||
msg += ", "
|
||||
}
|
||||
msg += tid
|
||||
}
|
||||
msg += "\nFor follow-up delegations, create NEW tasks (do not reuse completed task IDs).\n\n"
|
||||
}
|
||||
|
||||
// Render each delegation result
|
||||
for i, r := range artifacts.Results {
|
||||
msg += fmt.Sprintf("--- Result from %q ---\n%s\n", r.AgentKey, r.Content)
|
||||
|
||||
@@ -81,6 +81,7 @@ func (dm *DelegateManager) accumulateArtifacts(sourceAgentID uuid.UUID, arts *De
|
||||
}
|
||||
merged.Media = append(merged.Media, arts.Media...)
|
||||
merged.Results = append(merged.Results, arts.Results...)
|
||||
merged.CompletedTaskIDs = append(merged.CompletedTaskIDs, arts.CompletedTaskIDs...)
|
||||
dm.pendingArtifacts.Store(key, &merged)
|
||||
}
|
||||
|
||||
|
||||
@@ -144,14 +144,14 @@ export function TraceDetailDialog({ traceId, onClose, getTrace, onNavigateTrace
|
||||
{trace.input_preview && (
|
||||
<div className="rounded-md border p-3">
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">Input</p>
|
||||
<pre className="max-h-[40vh] overflow-y-auto whitespace-pre-wrap break-all text-sm">{trace.input_preview}</pre>
|
||||
<pre className="max-h-[20vh] overflow-y-auto whitespace-pre-wrap break-all text-sm">{trace.input_preview}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trace.output_preview && (
|
||||
<div className="rounded-md border p-3">
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">Output</p>
|
||||
<pre className="max-h-[40vh] overflow-y-auto whitespace-pre-wrap break-all text-sm">{trace.output_preview}</pre>
|
||||
<pre className="max-h-[20vh] overflow-y-auto whitespace-pre-wrap break-all text-sm">{trace.output_preview}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -245,7 +245,7 @@ function SpanTreeNode({ node, depth }: { node: SpanNode; depth: number }) {
|
||||
|
||||
{/* Expanded detail panel */}
|
||||
{detailOpen && (
|
||||
<div className="space-y-2 overflow-hidden border-t px-3 py-2">
|
||||
<div className="max-h-[50vh] space-y-2 overflow-y-auto border-t px-3 py-2">
|
||||
{span.model && (
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground">Model:</span> {span.provider}/{span.model}
|
||||
@@ -277,7 +277,7 @@ function SpanTreeNode({ node, depth }: { node: SpanNode; depth: number }) {
|
||||
{span.input_preview && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Input:</p>
|
||||
<pre className="mt-1 max-h-[40vh] overflow-y-auto break-all whitespace-pre-wrap rounded bg-muted/50 p-2 text-xs">
|
||||
<pre className="mt-1 max-h-[20vh] overflow-y-auto break-all whitespace-pre-wrap rounded bg-muted/50 p-2 text-xs">
|
||||
{span.input_preview}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -285,7 +285,7 @@ function SpanTreeNode({ node, depth }: { node: SpanNode; depth: number }) {
|
||||
{span.output_preview && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Output:</p>
|
||||
<pre className="mt-1 max-h-[40vh] overflow-y-auto break-all whitespace-pre-wrap rounded bg-muted/50 p-2 text-xs">
|
||||
<pre className="mt-1 max-h-[20vh] overflow-y-auto break-all whitespace-pre-wrap rounded bg-muted/50 p-2 text-xs">
|
||||
{span.output_preview}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user