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:
viettranxandClaude Opus 4.6 committed 2026-03-03 23:31:40 +07:00
1 parent 32f9c99a71
commit 9b82128392
4 files changed
+106 -9

No files matched your search

+88 -4
View File
@@ -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)
+12
View File
@@ -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)
+1
View File
@@ -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>