Files
yatulandClaude Opus 5 305bd7c50d fix(teams): grant a delegated lead read access to its own team (#1537)
* fix(teams): scope team tasks to the delegation origin, not the delivery channel

A delegated run is delivered on the internal "delegate" channel while its real
origin is preserved separately — buildAgentLinkRunRequest is explicit about it:

    // preserves the origin's authorization-bearing identity while keeping
    // delegation on its internal delivery channel.
    Channel:          "delegate",
    WorkspaceChannel: req.Channel,
    WorkspaceChatID:  req.ChatID,

The team tools did not consult that origin, so a task created by a lead reached
through delegate was stamped with the delivery channel. Nothing is registered
for it, so every notification about that task — completion, failure, blocker
escalation, ask_user — was dropped:

    unknown channel for outbound message channel=delegate

The delegatee's first answer still arrived, because it travels back as the
delegation result rather than through a channel; everything the lead said after
the delegation closed was lost. One session produced 16 such drops, including a
blocker escalation the user needed to see.

Add OriginChannelFromCtx / OriginChatIDFromCtx next to the existing workspace
scope propagation helpers, and use them where team scoping and notification
routing are decided: task records, list/search scoping, dispatch fallbacks,
event payloads, escalation tasks, ask_user and leader notifications. The
resolution is the identity when no delegation origin is present, so
non-delegated flows are unchanged.

Deliberately not touched: the channel used in authorization decisions
(checkTeamAccess, requireLead, approve/reject lead bypass). Those ask "how did
this call arrive", not "where should the answer go", and widening them is a
separate question.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(teams): cover delegated-lead completion routing and origin isolation

Triage on #1529 named two gates this PR had not met: regression coverage for
delegated lead task completion, and a guard that unrelated origins cannot
receive the notification. The existing tests only covered what create persists.

TestDelegatedLeadCompletionNotifiesOrigin completes a task raised in a delegated
context and asserts the completion event is addressed to the caller's origin.
Reverting team_event_helpers.go to the delivery channel fails it with
"addressed to delegate/system".

TestCompletionNotificationStaysWithinItsOwnOrigin puts two tasks from different
origins on one board, completes one, and asserts no completion notification
carries the other origin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(teams): grant a delegated lead read access to its own team

Fixes #1535. Second attempt: the first one set RunRequest.TeamWorkspace on the
delegated run, which loop_context.go rejects outright and deliberately so — that
field also becomes ToolWorkspace, which would displace the exchange outputs
directory. Every delegation then died at setup with "invalid delegation artifact
workspace". This does not go near that field.

The separation the lead needs already exists in the code. A lead addressed
directly resolves its team at loop_context.go:285-321 and gets

    ctx = tools.WithToolTeamWorkspace(ctx, wsDir)
    ctx = tools.WithToolTeamRoot(ctx, teamRoot)

with no WithToolWorkspace call — a read allowance, not an override. Only the
!isArtifactDelegation gate keeps a delegated lead out of it. So the grant is
made inside the artifact branch instead, from the same inputs, and the guard on
req.TeamWorkspace stays exactly as strict as it was.

Both paths are needed. The workspace alone covers only the lead's own chat leaf;
the deliverable in the report sits at teams/<id>/system/review-....md, written by
a member under a different chat scope. buildAllowedPrefixes adds the team root
for reads and not for writes (filesystem.go:366-370), which is precisely the
asymmetry this case wants: the lead reads the team's output, per-chat write
isolation is untouched.

Team ID is deliberately not set. It switches on the workspace interceptor's
write validation, file-change broadcast and task attachment (workspace_
interceptor.go:36,114,203) — none of which a delegated run should trigger, and
none of which reading needs.

Ambiguity is not resolved silently, as triage asked: an agent leading more than
one active team gets nothing. store.GetTeamForAgent would answer in one call but
it is ORDER BY (lead_agent_id = $1) DESC LIMIT 1 and would quietly pick a team.

Hermeticity is unchanged: send_file and message still refuse inside an artifact
run, publication still happens through delegation outputs on completion.

Tests: TestInjectContext_DelegatedLeadReadsTeamWithoutDisplacingOutputs drives
injectContext through the guard that broke the first attempt and pins both
halves — the run is not refused, and ToolWorkspace stays the outputs directory.
Resolution rules, the shared/isolated path shapes, read-not-write on the team
root, and send_file staying blocked are covered alongside.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(teams): pin that the delegated lead resolver does not re-scope by tenant

l.dataDir arrives already tenant-scoped from resolver.go (config.TenantDataDir),
which is why resolveDelegatedLeadTeamRead must not apply TenantLayer itself.
That was carried only by a comment, here and in the sibling branch of
injectContext, and nothing failed if someone added the layer back.

The failure it guards against is quiet: the path stays plausible, it just gains
a second tenant segment — teams/<id> under tenants/<slug>/tenants/<slug> — so
the lead is handed a directory its own tasks never write to, and reads come back
empty rather than denied.

Verified the assertion has teeth by reintroducing the layer: the subtest fails
with "tenant segment appears 2 times". Prompted by #1546 in this same area,
where a contract that lived outside the unit under test went unpinned and the
feature shipped green and did nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:05:49 +07:00

494 lines
20 KiB
Go

package agent
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/internal/workspace"
)
// contextSetupResult holds the outputs of injectContext that are needed by the main loop.
type contextSetupResult struct {
ctx context.Context
resolvedTeamSettings json.RawMessage
}
// injectContext enriches the context with agent, tenant, user, workspace, and tool-level
// 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)
}
if l.id != "" {
ctx = store.WithAgentKey(ctx, l.id)
}
// Inject tenant into context for tool-level tenant scoping (spawn, MCP, etc.)
if l.tenantID != uuid.Nil {
ctx = store.WithTenantID(ctx, l.tenantID)
}
// Propagate the configured agent budget to every nested model call.
ctx = store.WithAgentContextWindow(ctx, l.contextWindow)
ctx = store.WithAgentMaxTokens(ctx, l.effectiveMaxTokens())
// Inject user ID into context for per-user scoping (memory, context files, etc.)
if req.UserID != "" {
ctx = store.WithUserID(ctx, req.UserID)
}
// Resolve merged tenant user identity for credential lookups.
// Keeps UserID unchanged (session/workspace scoping) but sets a separate
// CredentialUserID for SecureCLI, MCP, and other per-user features.
if l.userResolver != nil && req.UserID != "" && store.ExplicitCredentialUserIDFromContext(ctx) == "" {
credUserID := l.resolveCredentialUserID(ctx, *req)
if credUserID != "" && credUserID != req.UserID {
ctx = store.WithCredentialUserID(ctx, credUserID)
}
}
// Inject agent type into context for interceptor routing
if l.agentType != "" {
ctx = store.WithAgentType(ctx, l.agentType)
}
// Inject self-evolve flag for predefined agents that can update SOUL.md
if l.selfEvolve {
ctx = store.WithSelfEvolve(ctx, true)
}
// Inject original sender ID for group file writer permission checks
if req.SenderID != "" {
ctx = store.WithSenderID(ctx, req.SenderID)
}
// Inject sender display name for bootstrap auto-contact
if req.SenderName != "" {
ctx = store.WithSenderName(ctx, req.SenderName)
}
// Inject caller role so RBAC-aware permission checks (CheckFileWriterPermission,
// CheckCronPermission) can bypass per-user grants for authenticated admins
// dispatched from dashboard or other trusted sources (#915).
if req.Role != "" {
ctx = store.WithRole(ctx, req.Role)
}
// Inject global + per-agent builtin tool settings (tier 1+3).
// Media/provider-chain tools read the merged view via BuiltinToolSettingsFromCtx.
if l.builtinToolSettings != nil {
ctx = tools.WithBuiltinToolSettings(ctx, l.builtinToolSettings)
}
// Inject tenant-layer tool settings (tier 2). Merge with per-agent happens
// at read time — per-agent still wins at tool-name level.
if l.tenantToolSettings != nil {
ctx = tools.WithTenantToolSettings(ctx, l.tenantToolSettings)
}
// Inject tenant-specific allowed paths for filesystem tools.
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)
if req.ChannelType != "" {
ctx = tools.WithToolChannelType(ctx, req.ChannelType)
}
if len(req.TelegramManagerPermissions) > 0 {
ctx = tools.WithTelegramManagerPermissions(ctx, req.TelegramManagerPermissions)
}
// Inject per-agent overrides from DB so tools honor per-agent settings.
if l.restrictToWs != nil {
ctx = tools.WithRestrictToWorkspace(ctx, *l.restrictToWs)
}
if l.subagentsCfg != nil {
ctx = tools.WithSubagentConfig(ctx, l.subagentsCfg)
}
// Pass the agent's model and provider so subagents inherit the correct combo.
if l.model != "" {
ctx = tools.WithParentModel(ctx, l.model)
}
if l.provider != nil {
ctx = tools.WithParentProvider(ctx, l.provider.Name())
}
if l.memoryCfg != nil {
ctx = tools.WithMemoryConfig(ctx, l.memoryCfg)
}
var waitToolCfg *config.WaitToolPolicy
if l.agentToolPolicy != nil && l.agentToolPolicy.Wait != nil {
waitToolCfg = l.agentToolPolicy.Wait
ctx = tools.WithWaitToolConfig(ctx, waitToolCfg)
}
if l.agentToolPolicy != nil && l.agentToolPolicy.RateLimitPerHour > 0 {
ctx = tools.WithToolRateLimitOverride(ctx, l.agentToolPolicy.RateLimitPerHour)
}
if l.sandboxCfg != nil {
ctx = tools.WithSandboxConfig(ctx, l.sandboxCfg)
}
if l.shellDenyGroups != nil {
ctx = store.WithShellDenyGroups(ctx, l.shellDenyGroups)
}
// Workspace scope propagation (delegation origin → workspace tools).
if req.WorkspaceChannel != "" {
ctx = tools.WithWorkspaceChannel(ctx, req.WorkspaceChannel)
}
// WorkspaceChatID drives vault chat_id isolation in isolated teams. Callers
// that don't set it explicitly fall back to req.ChatID — the chat segment
// used for workspace path layering — so the vault filter activates uniformly
// across every RunRequest entry point (WS direct, HTTP, cron, subagent).
effectiveWorkspaceChatID := req.WorkspaceChatID
if effectiveWorkspaceChatID == "" {
effectiveWorkspaceChatID = req.ChatID
}
if effectiveWorkspaceChatID != "" {
ctx = tools.WithWorkspaceChatID(ctx, effectiveWorkspaceChatID)
}
if req.TeamTaskID != "" {
ctx = tools.WithTeamTaskID(ctx, req.TeamTaskID)
}
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.
// Seeding must run before buildMessages→resolveContextFiles reads context files.
// Team sessions skip seeding: members process tasks from leader, not end-user onboarding.
isTeamSession := bootstrap.IsTeamSession(req.SessionKey)
channelMeta := l.buildChannelMeta(req)
setup := l.getOrCreateUserSetup(ctx, req.UserID, req.Channel, isTeamSession, channelMeta)
// Workspace resolution (layered pipeline).
// 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 !isArtifactDelegation && l.workspace != "" && req.UserID != "" {
ws := setup.workspace
if ws == "" {
ws = l.workspace
}
// Apply user isolation layer via pipeline.
shared := l.shouldShareWorkspace(req.UserID, req.PeerKind)
if shared {
ctx = store.WithSharedContext(ctx)
}
effectiveWorkspace := tools.ResolveWorkspace(ws,
tools.UserChatLayer(tools.SanitizePathSegment(req.UserID), shared),
)
if l.shouldShareMemory() {
ctx = store.WithSharedMemory(ctx)
}
if l.shouldShareKnowledgeGraph() {
ctx = store.WithSharedKG(ctx)
}
if l.shouldShareSessions() {
ctx = store.WithSharedSessions(ctx)
}
if err := os.MkdirAll(effectiveWorkspace, 0755); err != nil {
// Stale stored workspace (e.g. Docker-era /app/workspace/ on bare-metal host)
// would propagate as cmd.Dir into exec tools, where Linux's clone+chdir+execve
// failure surfaces as a misleading "fork/exec PATH: no such file or directory"
// — same message users would see for a missing binary. Fall back to the system
// default workspace (already created at startup) so tools keep working while
// the warning surfaces the data drift for operators.
slog.Warn("failed to create user workspace directory; falling back to system default",
"workspace", effectiveWorkspace, "fallback", l.workspace, "user", req.UserID, "error", err)
effectiveWorkspace = l.workspace
}
ctx = tools.WithToolWorkspace(ctx, effectiveWorkspace)
} 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)
// A delegated lead is otherwise the only team agent running without its
// own team in context, leaving the team's deliverables unreadable to it
// (#1535). Read allowance only: the active workspace set above stays the
// exchange outputs directory, and req.TeamWorkspace is still rejected as
// an override by the guard above.
if read := l.resolveDelegatedLeadTeamRead(ctx, req); read.ok() {
ctx = tools.WithToolTeamWorkspace(ctx, read.workspace)
ctx = tools.WithToolTeamRoot(ctx, read.root)
}
}
// Team workspace: dispatched task overrides default workspace.
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
// "fork/exec PATH: no such file or directory" from any tool exec.
slog.Warn("failed to create team workspace directory; keeping previous workspace",
"workspace", req.TeamWorkspace, "error", err)
} else {
ctx = tools.WithToolTeamWorkspace(ctx, req.TeamWorkspace)
ctx = tools.WithToolWorkspace(ctx, req.TeamWorkspace)
}
}
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.
// l.dataDir is already tenant-scoped (see resolver.go: config.TenantDataDir),
// so TenantLayer must NOT be reapplied here — doing so double-joins the
// tenant segment (tenants/<slug>/tenants/<slug>/teams/<id>).
if teamUUID, err := uuid.Parse(req.TeamID); err == nil && l.dataDir != "" {
teamRoot := tools.ResolveWorkspace(l.dataDir,
tools.TeamLayer(teamUUID),
)
ctx = tools.WithToolTeamRoot(ctx, teamRoot)
}
}
if !isArtifactDelegation && req.LeaderAgentID != "" {
ctx = tools.WithLeaderAgentID(ctx, req.LeaderAgentID)
}
// Team workspace: auto-resolve for agents with team membership (not dispatched).
// Lead agents default to team workspace; non-lead members keep own workspace.
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 !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 !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
if wsChat == "" {
wsChat = req.UserID
}
shared := tools.IsSharedWorkspace(team.Settings)
// Resolve team workspace via layered pipeline: team → user/chat.
// l.dataDir is already tenant-scoped (see resolver.go: config.TenantDataDir) —
// do NOT reapply TenantLayer here, it would double-join the tenant segment.
wsDir := tools.ResolveWorkspace(l.dataDir,
tools.TeamLayer(team.ID),
tools.UserChatLayer(wsChat, shared),
)
if err := os.MkdirAll(wsDir, 0750); err != nil {
// See note above on loop_context user workspace fallback. Skip the
// team workspace context-set so downstream tools don't inherit a
// path that would fail with a misleading "fork/exec PATH: ENOENT".
slog.Warn("failed to create team workspace directory; skipping team workspace ctx",
"workspace", wsDir, "error", err)
} else {
ctx = tools.WithToolTeamWorkspace(ctx, wsDir)
}
// Team root (no UserChatLayer): lets any team agent — leader or member —
// read files produced by peers under different chat/user scopes within
// the same team. Writes still default to wsDir above; team root only
// widens the allowed-prefix set for path boundary checks.
teamRoot := tools.ResolveWorkspace(l.dataDir,
tools.TeamLayer(team.ID),
)
ctx = tools.WithToolTeamRoot(ctx, teamRoot)
// Leader keeps personal workspace (set at line 110-132) as default.
// Team workspace accessible via ToolTeamWorkspaceFromCtx for delegation.
if req.TeamID == "" {
ctx = tools.WithToolTeamID(ctx, team.ID.String())
}
}
}
// 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
}
var teamWSConfig *workspace.TeamWorkspaceConfig
if resolvedTeamSettings != nil {
var cfg workspace.TeamWorkspaceConfig
if json.Unmarshal(resolvedTeamSettings, &cfg) == nil {
teamWSConfig = &cfg
}
}
resolver := workspace.NewResolver()
wc, wsErr := resolver.Resolve(ctx, workspace.ResolveParams{
// Filesystem path segment must use agent_key, not UUID — matches
// the v2 path in loop_pipeline_callbacks.go and the session_key
// anchor. See docs/agent-identity-conventions.md.
AgentID: l.id,
AgentType: l.agentType,
UserID: req.UserID,
ChatID: req.ChatID,
// TenantID/TenantSlug intentionally left empty: l.dataDir (BaseDir below)
// is already tenant-scoped (see resolver.go: config.TenantDataDir). Setting
// TenantID here would make resolveTeam/resolvePersonal reapply tenantPath()
// and double-join the tenant segment (tenants/<slug>/tenants/<slug>/...).
PeerKind: req.PeerKind,
TeamID: teamIDPtr,
TeamConfig: teamWSConfig,
BaseDir: l.dataDir,
})
if wsErr != nil {
slog.Warn("workspace resolution failed", "err", wsErr)
} else {
ctx = workspace.WithContext(ctx, wc)
}
}
// Persist agent UUID + user ID on the session (for querying/tracing)
if l.agentUUID != uuid.Nil || req.UserID != "" {
l.sessions.SetAgentInfo(ctx, req.SessionKey, l.agentUUID, req.UserID)
}
// Security: scan user message for injection patterns.
// Action is configurable: "log" (info), "warn" (default), "block" (reject message).
if l.inputGuard != nil {
if matches := l.inputGuard.Scan(req.Message); len(matches) > 0 {
matchStr := strings.Join(matches, ",")
switch l.injectionAction {
case "block":
slog.Warn("security.injection_blocked",
"agent", l.id, "user", req.UserID,
"patterns", matchStr, "message_len", len(req.Message),
)
return contextSetupResult{}, fmt.Errorf("message blocked: potential prompt injection detected (%s)", matchStr)
case "log":
slog.Info("security.injection_detected",
"agent", l.id, "user", req.UserID,
"patterns", matchStr, "message_len", len(req.Message),
)
default: // "warn"
slog.Warn("security.injection_detected",
"agent", l.id, "user", req.UserID,
"patterns", matchStr, "message_len", len(req.Message),
)
}
}
}
// Inject agent key into context for tool-level resolution (multiple agents share tool registry)
ctx = tools.WithToolAgentKey(ctx, l.id)
// Inject delivered media tracker so automatic output collection and direct
// media sends can coordinate within this run.
ctx = tools.WithDeliveredMedia(ctx, tools.NewDeliveredMedia())
// Security: truncate oversized user messages gracefully (feed truncation notice into LLM)
maxChars := l.maxMessageChars
if maxChars <= 0 {
maxChars = config.DefaultMaxMessageChars
}
if len(req.Message) > maxChars {
originalLen := len(req.Message)
req.Message = req.Message[:maxChars] +
fmt.Sprintf("\n\n[System: Message was truncated from %d to %d characters due to size limit. "+
"Please ask the user to send shorter messages or use the read_file tool for large content.]",
originalLen, maxChars)
slog.Warn("security.message_truncated",
"agent", l.id, "user", req.UserID,
"original_len", originalLen, "truncated_to", maxChars,
)
}
// Build RunContext from all resolved values and inject as single context key.
// This provides a typed, inspectable snapshot of all loop-injected context.
// Individual With* keys above remain for backward compat during transition.
providerName := ""
if l.provider != nil {
providerName = l.provider.Name()
}
// 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,
TenantID: l.tenantID,
UserID: req.UserID,
RunID: req.RunID,
SessionKey: req.SessionKey,
CredentialUserID: credUserID,
AgentType: l.agentType,
SenderID: req.SenderID,
SelfEvolve: l.selfEvolve,
SharedMemory: store.IsSharedMemory(ctx),
SharedKG: store.IsSharedKG(ctx),
SharedSessions: store.IsSharedSessions(ctx),
SharedContext: store.IsSharedContext(ctx),
RestrictToWorkspace: l.restrictToWs != nil && *l.restrictToWs,
BuiltinToolSettings: l.builtinToolSettings,
Channel: req.Channel,
ChannelType: req.ChannelType,
SubagentsCfg: l.subagentsCfg,
ParentModel: l.model,
ParentProvider: providerName,
MemoryCfg: l.memoryCfg,
SandboxCfg: l.sandboxCfg,
WaitToolCfg: waitToolCfg,
ShellDenyGroups: l.shellDenyGroups,
Workspace: tools.ToolWorkspaceFromCtx(ctx),
TeamWorkspace: tools.ToolTeamWorkspaceFromCtx(ctx),
TeamID: tools.ToolTeamIDFromCtx(ctx),
WorkspaceChannel: req.WorkspaceChannel,
WorkspaceChatID: effectiveWorkspaceChatID,
TeamIsolated: resolvedTeamSettings != nil && !tools.IsSharedWorkspace(resolvedTeamSettings),
TeamTaskID: req.TeamTaskID,
DelegationID: req.DelegationID,
LeaderAgentID: tools.LeaderAgentIDFromCtx(ctx),
AgentToolKey: l.id,
TenantAllowedPaths: tenantAllowedPaths,
}
ctx = store.WithRunContext(ctx, rc)
return contextSetupResult{
ctx: ctx,
resolvedTeamSettings: resolvedTeamSettings,
}, nil
}