package tools import ( "context" "fmt" "log/slog" "maps" "sync" "github.com/google/uuid" orchestration "github.com/nextlevelbuilder/goclaw/internal/childrun" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/sandbox" "github.com/nextlevelbuilder/goclaw/internal/store" ) // Tool execution context keys. // These replace mutable setter fields on tool instances, making tools thread-safe // for concurrent execution. Values are injected into context by the registry // and read by individual tools during Execute(). type toolContextKey string const ( ctxChannel toolContextKey = "tool_channel" ctxChannelType toolContextKey = "tool_channel_type" ctxChatID toolContextKey = "tool_chat_id" ctxPeerKind toolContextKey = "tool_peer_kind" ctxLocalKey toolContextKey = "tool_local_key" // composite key with topic/thread suffix for routing ctxSandboxKey toolContextKey = "tool_sandbox_key" ctxAsyncCB toolContextKey = "tool_async_cb" ctxWorkspace toolContextKey = "tool_workspace" ctxAgentKey toolContextKey = "tool_agent_key" ctxAgentPolicy toolContextKey = "tool_agent_policy" // per-agent tool policy for MCP bridge enforcement ctxSessionKey toolContextKey = "tool_session_key" // origin session key for announce routing ctxRunKind toolContextKey = "tool_run_kind" // "notification", "announce", "delegation" ctxSubagentScope toolContextKey = "subagent_task_scope" ctxSubagentTaskID toolContextKey = "subagent_task_id" ctxSubagentDepth toolContextKey = "subagent_depth" ctxChildRunLease toolContextKey = "child_run_lease" ctxTelegramManagerPermissions toolContextKey = "telegram_manager_permissions" ) func withSubagentExecution(ctx context.Context, scope TaskScope, taskID string, depth int, lease *orchestration.ChildRunLease) context.Context { ctx = context.WithValue(ctx, ctxSubagentScope, scope) ctx = context.WithValue(ctx, ctxSubagentTaskID, taskID) ctx = context.WithValue(ctx, ctxSubagentDepth, depth) return context.WithValue(ctx, ctxChildRunLease, lease) } func childRunLeaseFromContext(ctx context.Context) *orchestration.ChildRunLease { lease, _ := ctx.Value(ctxChildRunLease).(*orchestration.ChildRunLease) return lease } // withDelegatedAgentExecution starts a fresh semantic spawn tree for the target // agent while retaining the admission lease used by synchronous continuations. func withDelegatedAgentExecution(ctx context.Context, lease *orchestration.ChildRunLease) context.Context { ctx = context.WithValue(ctx, ctxSubagentScope, TaskScope{}) ctx = context.WithValue(ctx, ctxSubagentTaskID, "") ctx = context.WithValue(ctx, ctxSubagentDepth, 0) ctx = WithSubagentConfig(ctx, nil) return context.WithValue(ctx, ctxChildRunLease, lease) } func subagentScopeFromContext(ctx context.Context) TaskScope { if scope, ok := ctx.Value(ctxSubagentScope).(TaskScope); ok { if scope.TenantID != uuid.Nil || scope.RootAgentID != uuid.Nil || scope.RootAgentKey != "" { return scope } } return TaskScope{ TenantID: store.TenantIDFromContext(ctx), RootAgentID: store.AgentIDFromContext(ctx), RootAgentKey: ToolAgentKeyFromCtx(ctx), } } func subagentTaskIDFromContext(ctx context.Context) string { id, _ := ctx.Value(ctxSubagentTaskID).(string) return id } func subagentDepthFromContext(ctx context.Context, fallback int) int { if depth, ok := ctx.Value(ctxSubagentDepth).(int); ok { return depth } return fallback } // childRunContinuationLineage returns structural admission lineage. It is // intentionally separate from semantic sub-agent depth, which resets at every // Agent Link delegation boundary. func childRunContinuationLineage( ctx context.Context, fallbackParentTaskID string, fallbackDepth int, ) (string, int) { if lease := childRunLeaseFromContext(ctx); lease != nil { if parentTaskID, parentDepth, ok := lease.ContinuationParent(); ok { return parentTaskID, parentDepth + 1 } } return fallbackParentTaskID, fallbackDepth } // ctxRateLimitOverride carries a per-agent tool rate limit (calls/hour) that // overrides the global tools.rate_limit_per_hour. 0 means "use the global". const ctxRateLimitOverride toolContextKey = "tool_rate_limit_override" // Well-known channel names used for routing and access control. const ( ChannelSystem = "system" ChannelDashboard = "dashboard" ChannelTeammate = "teammate" ) // MediaPathLoader resolves a media ID to a local file path. // Used by media analysis tools (read_document, read_audio, read_video). type MediaPathLoader interface { LoadPath(id string) (string, error) } // MediaPathRootProvider optionally exposes the managed root that authorizes // legacy media returned by MediaPathLoader. Implementations must return a // stable root; callers still validate containment and regular-file semantics. type MediaPathRootProvider interface { MediaRootPath() string } func WithToolChannel(ctx context.Context, channel string) context.Context { return context.WithValue(ctx, ctxChannel, channel) } func ToolChannelFromCtx(ctx context.Context) string { v, _ := ctx.Value(ctxChannel).(string) return v } func WithToolChannelType(ctx context.Context, channelType string) context.Context { return context.WithValue(ctx, ctxChannelType, channelType) } func ToolChannelTypeFromCtx(ctx context.Context) string { if v, _ := ctx.Value(ctxChannelType).(string); v != "" { return v } if rc := store.RunContextFromCtx(ctx); rc != nil { return rc.ChannelType } return "" } func WithTelegramManagerPermissions(ctx context.Context, permissions []string) context.Context { return context.WithValue(ctx, ctxTelegramManagerPermissions, permissions) } func TelegramManagerPermissionsFromCtx(ctx context.Context) []string { v, _ := ctx.Value(ctxTelegramManagerPermissions).([]string) return v } func WithToolChatID(ctx context.Context, chatID string) context.Context { return context.WithValue(ctx, ctxChatID, chatID) } func ToolChatIDFromCtx(ctx context.Context) string { v, _ := ctx.Value(ctxChatID).(string) return v } func WithToolPeerKind(ctx context.Context, peerKind string) context.Context { return context.WithValue(ctx, ctxPeerKind, peerKind) } func ToolPeerKindFromCtx(ctx context.Context) string { v, _ := ctx.Value(ctxPeerKind).(string) return v } // WithToolLocalKey injects the composite local key (e.g. "-100123:topic:42") into context. // Used by delegation/subagent to preserve topic routing info for announce-back. func WithToolLocalKey(ctx context.Context, localKey string) context.Context { return context.WithValue(ctx, ctxLocalKey, localKey) } func ToolLocalKeyFromCtx(ctx context.Context) string { v, _ := ctx.Value(ctxLocalKey).(string) return v } func WithToolSandboxKey(ctx context.Context, key string) context.Context { return context.WithValue(ctx, ctxSandboxKey, key) } func ToolSandboxKeyFromCtx(ctx context.Context) string { v, _ := ctx.Value(ctxSandboxKey).(string) return v } func WithToolAsyncCB(ctx context.Context, cb AsyncCallback) context.Context { return context.WithValue(ctx, ctxAsyncCB, cb) } func ToolAsyncCBFromCtx(ctx context.Context) AsyncCallback { v, _ := ctx.Value(ctxAsyncCB).(AsyncCallback) return v } func WithToolWorkspace(ctx context.Context, ws string) context.Context { return context.WithValue(ctx, ctxWorkspace, ws) } func ToolWorkspaceFromCtx(ctx context.Context) string { if v, ok := ctx.Value(ctxWorkspace).(string); ok { return v } if rc := store.RunContextFromCtx(ctx); rc != nil { return rc.Workspace } return "" } // WithToolAgentKey injects the calling agent's key into context. // Multiple agents share a single tool registry; the agent key // lets tools like spawn/subagent identify which agent is the parent. func WithToolAgentKey(ctx context.Context, key string) context.Context { return context.WithValue(ctx, ctxAgentKey, key) } func ToolAgentKeyFromCtx(ctx context.Context) string { if v, _ := ctx.Value(ctxAgentKey).(string); v != "" { return v } if rc := store.RunContextFromCtx(ctx); rc != nil { return rc.AgentToolKey } return "" } // WithToolAgentPolicy injects the calling agent's per-agent tool policy into // context, so the MCP bridge server (internal/mcp/bridge_server.go) can // enforce the same policy-filtered tool allowlist the normal agent loop uses, // instead of exposing its full BridgeToolNames set unconditionally to every // caller regardless of agent identity. func WithToolAgentPolicy(ctx context.Context, policy *config.ToolPolicySpec) context.Context { return context.WithValue(ctx, ctxAgentPolicy, policy) } // ToolAgentPolicyFromCtx returns the calling agent's per-agent tool policy, or // nil if none was injected (e.g. no verified agent context on the request). func ToolAgentPolicyFromCtx(ctx context.Context) *config.ToolPolicySpec { policy, _ := ctx.Value(ctxAgentPolicy).(*config.ToolPolicySpec) return policy } // WithToolSessionKey injects the parent's session key so subagent announce // can route results back to the exact same session (required for WS where // session keys don't follow BuildScopedSessionKey format). func WithToolSessionKey(ctx context.Context, key string) context.Context { return context.WithValue(ctx, ctxSessionKey, key) } func ToolSessionKeyFromCtx(ctx context.Context) string { v, _ := ctx.Value(ctxSessionKey).(string) return v } // WithToolRateLimitOverride sets a per-agent tool rate limit (calls/hour) that // overrides the global tools.rate_limit_per_hour for tools executed under ctx. func WithToolRateLimitOverride(ctx context.Context, perHour int) context.Context { return context.WithValue(ctx, ctxRateLimitOverride, perHour) } // ToolRateLimitOverrideFromCtx returns the per-agent override, or 0 if unset. func ToolRateLimitOverrideFromCtx(ctx context.Context) int { v, _ := ctx.Value(ctxRateLimitOverride).(int) return v } // WithRunKind injects the run classification (e.g. "notification") into context. func WithRunKind(ctx context.Context, kind string) context.Context { return context.WithValue(ctx, ctxRunKind, kind) } // RunKindFromCtx returns the run kind from context, or empty string. func RunKindFromCtx(ctx context.Context) string { v, _ := ctx.Value(ctxRunKind).(string) return v } // RunKindNotification is the run kind for team task notification runs. // Leader agents in this mode can only relay status — mutations are blocked. const RunKindNotification = "notification" // --- Builtin tool settings (3-tier overlay, tier-1 reserved) --- // // Tool config resolution order (most specific wins): // 1. (reserved — future per-agent override) // 2. Tenant override (builtin_tool_tenant_configs.settings, via WithTenantToolSettings) // 3. Global default (builtin_tools.settings, via WithBuiltinToolSettings — resolver-loaded) // 4. Hardcoded (tool internal default when the map has no entry) // // `WithBuiltinToolSettings` carries the resolver-loaded global defaults (tier 3). // If per-agent overrides are added later, they can share this ctx key (same map) or // introduce a dedicated key — the merge function's precedence (builtin ctx key wins // over tenant) already reflects "more specific wins". // // Merge happens at tool-name level: if both tiers define `web_search`, the builtin // ctx-key value wins wholesale (no field-level deep merge inside the JSON blob). const ( ctxBuiltinToolSettings toolContextKey = "tool_builtin_settings" ctxTenantToolSettings toolContextKey = "tool_tenant_settings" ) // BuiltinToolSettings maps tool name → settings JSON bytes. type BuiltinToolSettings map[string][]byte // WithBuiltinToolSettings injects the per-agent + global tool settings map. // Tier 1 + tier 3 in the overlay (coalesced by the resolver). func WithBuiltinToolSettings(ctx context.Context, settings BuiltinToolSettings) context.Context { return context.WithValue(ctx, ctxBuiltinToolSettings, settings) } // WithTenantToolSettings injects the tenant-layer tool settings map (tier 2). // Loaded by the resolver via BuiltinToolTenantConfigStore.ListAllSettings. func WithTenantToolSettings(ctx context.Context, settings BuiltinToolSettings) context.Context { return context.WithValue(ctx, ctxTenantToolSettings, settings) } // TenantToolSettingsFromCtx returns the raw tenant-layer map without merging. // Use BuiltinToolSettingsFromCtx for the merged view that tools should read. func TenantToolSettingsFromCtx(ctx context.Context) BuiltinToolSettings { v, _ := ctx.Value(ctxTenantToolSettings).(BuiltinToolSettings) return v } // BuiltinToolSettingsFromCtx returns the merged tool settings view. // // Merge semantics (current wiring — tier 1 per-agent override not yet loaded): // - `WithBuiltinToolSettings` carries tier 3 (global defaults) // - `WithTenantToolSettings` carries tier 2 (tenant admin override) // - Tenant wins over global at tool-name level (no field-level deep merge) // // When a future phase adds tier 1 (per-agent override), it will either // introduce a third ctx key that outranks both, OR the resolver will merge // tier 1 into the builtin-ctx-key map before injection. In the latter case, // semantics remain correct so long as tier 1 is layered ABOVE tenant — the // resolver will need a split then. // // Fast paths: single-tier or empty merge returns the underlying map directly // with zero allocations. Merge only runs when BOTH layers have entries. func BuiltinToolSettingsFromCtx(ctx context.Context) BuiltinToolSettings { global, _ := ctx.Value(ctxBuiltinToolSettings).(BuiltinToolSettings) tenant, _ := ctx.Value(ctxTenantToolSettings).(BuiltinToolSettings) if len(global) == 0 && len(tenant) == 0 { // RunContext fallback (existing behavior — subagent runs inherit // parent's BuiltinToolSettings through RunContext serialization). if rc := store.RunContextFromCtx(ctx); rc != nil && rc.BuiltinToolSettings != nil { return BuiltinToolSettings(rc.BuiltinToolSettings) } return nil } // Fast paths — no allocation when only one tier is active. if len(tenant) == 0 { return global } if len(global) == 0 { return tenant } // Both tiers present: layer tenant override on top of global defaults. merged := make(BuiltinToolSettings, len(global)+len(tenant)) maps.Copy(merged, global) maps.Copy(merged, tenant) return merged } // --- Per-agent restrict_to_workspace override --- const ctxRestrictWs toolContextKey = "tool_restrict_to_workspace" // WithRestrictToWorkspace injects a per-agent restrict_to_workspace override into context. func WithRestrictToWorkspace(ctx context.Context, restrict bool) context.Context { return context.WithValue(ctx, ctxRestrictWs, restrict) } // RestrictFromCtx returns the per-agent restrict_to_workspace override. func RestrictFromCtx(ctx context.Context) (bool, bool) { v, ok := ctx.Value(ctxRestrictWs).(bool) return v, ok } func effectiveRestrict(ctx context.Context, toolDefault bool) bool { // Multi-tenant security: always restrict agents to their workspace. // Agents must not access files outside their tenant-scoped workspace. return true } // --- Parent agent model (for subagent inheritance) --- const ctxParentModel toolContextKey = "tool_parent_model" // WithParentModel sets the parent agent's model in context so subagents can inherit it. func WithParentModel(ctx context.Context, model string) context.Context { return context.WithValue(ctx, ctxParentModel, model) } // ParentModelFromCtx returns the parent agent's model from context. func ParentModelFromCtx(ctx context.Context) string { if v, _ := ctx.Value(ctxParentModel).(string); v != "" { return v } if rc := store.RunContextFromCtx(ctx); rc != nil { return rc.ParentModel } return "" } // --- Parent agent provider (for subagent inheritance) --- const ctxParentProvider toolContextKey = "tool_parent_provider" // WithParentProvider sets the parent agent's provider name in context so subagents inherit it. func WithParentProvider(ctx context.Context, providerName string) context.Context { return context.WithValue(ctx, ctxParentProvider, providerName) } // ParentProviderFromCtx returns the parent agent's provider name from context. func ParentProviderFromCtx(ctx context.Context) string { if v, _ := ctx.Value(ctxParentProvider).(string); v != "" { return v } if rc := store.RunContextFromCtx(ctx); rc != nil { return rc.ParentProvider } return "" } // --- Per-agent subagent config override --- const ctxSubagentCfg toolContextKey = "tool_subagent_config" func WithSubagentConfig(ctx context.Context, cfg *config.SubagentsConfig) context.Context { return context.WithValue(ctx, ctxSubagentCfg, cfg) } func SubagentConfigFromCtx(ctx context.Context) *config.SubagentsConfig { if raw := ctx.Value(ctxSubagentCfg); raw != nil { v, _ := raw.(*config.SubagentsConfig) return v } if rc := store.RunContextFromCtx(ctx); rc != nil { return rc.SubagentsCfg } return nil } // --- Per-agent memory config override --- const ctxMemoryCfg toolContextKey = "tool_memory_config" func WithMemoryConfig(ctx context.Context, cfg *config.MemoryConfig) context.Context { return context.WithValue(ctx, ctxMemoryCfg, cfg) } func MemoryConfigFromCtx(ctx context.Context) *config.MemoryConfig { if v, _ := ctx.Value(ctxMemoryCfg).(*config.MemoryConfig); v != nil { return v } if rc := store.RunContextFromCtx(ctx); rc != nil { return rc.MemoryCfg } return nil } // --- Per-agent wait tool config override --- const ctxWaitToolCfg toolContextKey = "tool_wait_config" func WithWaitToolConfig(ctx context.Context, cfg *config.WaitToolPolicy) context.Context { return context.WithValue(ctx, ctxWaitToolCfg, cfg) } func WaitToolConfigFromCtx(ctx context.Context) *config.WaitToolPolicy { if v, _ := ctx.Value(ctxWaitToolCfg).(*config.WaitToolPolicy); v != nil { return v } if rc := store.RunContextFromCtx(ctx); rc != nil { return rc.WaitToolCfg } return nil } // --- Team ID propagation (task dispatch → workspace tools) --- const ctxTeamID toolContextKey = "tool_team_id" // WithToolTeamID injects the dispatching team's ID into context so team // tools (team_tasks) and the WorkspaceInterceptor resolve // the correct team when the agent belongs to multiple teams. func WithToolTeamID(ctx context.Context, teamID string) context.Context { return context.WithValue(ctx, ctxTeamID, teamID) } // ToolTeamIDFromCtx returns the dispatching team's ID from context. func ToolTeamIDFromCtx(ctx context.Context) string { if v, ok := ctx.Value(ctxTeamID).(string); ok { return v } if rc := store.RunContextFromCtx(ctx); rc != nil { return rc.TeamID } return "" } // --- Team workspace path (accessible but not default) --- const ctxTeamWorkspace toolContextKey = "tool_team_workspace" // WithToolTeamWorkspace stores the team shared workspace directory path. // File tools allow access to this path even when restrict_to_workspace is true. func WithToolTeamWorkspace(ctx context.Context, dir string) context.Context { return context.WithValue(ctx, ctxTeamWorkspace, dir) } // ToolTeamWorkspaceFromCtx returns the team shared workspace directory path. func ToolTeamWorkspaceFromCtx(ctx context.Context) string { if v, ok := ctx.Value(ctxTeamWorkspace).(string); ok { return v } if rc := store.RunContextFromCtx(ctx); rc != nil { return rc.TeamWorkspace } return "" } // --- Team root (team-wide shared root, above UserChatLayer) --- const ctxTeamRoot toolContextKey = "tool_team_root" // WithToolTeamRoot stores the team-wide root directory (e.g. /app/workspace/teams//) // without the UserChatLayer suffix. Any agent belonging to the team (leader or member) sees // this path as an allowed prefix so file tools can read across chat/user scopes within the // same team. Per-chat write isolation is preserved by still resolving writes against the // agent's own workspace first; this key only widens the allowed-prefix set for path checks. func WithToolTeamRoot(ctx context.Context, dir string) context.Context { return context.WithValue(ctx, ctxTeamRoot, dir) } // ToolTeamRootFromCtx returns the team-wide root directory, or empty if not set. func ToolTeamRootFromCtx(ctx context.Context) string { if v, _ := ctx.Value(ctxTeamRoot).(string); v != "" { return v } return "" } // --- Team task ID propagation (delegation origin → workspace tools) --- const ctxTeamTaskID toolContextKey = "tool_team_task_id" // WithTeamTaskID injects the delegation's team task ID into context // so workspace tools can auto-link files to the active task. func WithTeamTaskID(ctx context.Context, taskID string) context.Context { return context.WithValue(ctx, ctxTeamTaskID, taskID) } // TeamTaskIDFromCtx returns the delegation's team task ID from context. func TeamTaskIDFromCtx(ctx context.Context) string { if v, ok := ctx.Value(ctxTeamTaskID).(string); ok { return v } if rc := store.RunContextFromCtx(ctx); rc != nil { return rc.TeamTaskID } return "" } // --- Delegation ID propagation (delegate_tool → vault_interceptor) --- const ctxDelegationID toolContextKey = "tool_delegation_id" // WithDelegationID injects the delegation identifier into context so vault // documents created during the delegated task can be tagged in metadata for // Phase 05 auto-linking. func WithDelegationID(ctx context.Context, id string) context.Context { return context.WithValue(ctx, ctxDelegationID, id) } // DelegationIDFromCtx returns the active delegation ID. Falls back to // RunContext when no explicit context key is present. func DelegationIDFromCtx(ctx context.Context) string { if v, ok := ctx.Value(ctxDelegationID).(string); ok { return v } if rc := store.RunContextFromCtx(ctx); rc != nil { return rc.DelegationID } return "" } // --- Leader agent ID propagation (team task dispatch → memory interceptor) --- const ctxLeaderAgentID toolContextKey = "tool_leader_agent_id" // WithLeaderAgentID injects the team leader's agent UUID string into context // so the memory interceptor can fallback-read leader's memory for team members. func WithLeaderAgentID(ctx context.Context, id string) context.Context { return context.WithValue(ctx, ctxLeaderAgentID, id) } // LeaderAgentIDFromCtx returns the leader's agent UUID string from context. func LeaderAgentIDFromCtx(ctx context.Context) string { if v, ok := ctx.Value(ctxLeaderAgentID).(string); ok { return v } if rc := store.RunContextFromCtx(ctx); rc != nil { return rc.LeaderAgentID } return "" } // --- Workspace scope propagation (delegation origin) --- const ( ctxWsChannel toolContextKey = "tool_workspace_channel" ctxWsChatID toolContextKey = "tool_workspace_chat_id" ) func WithWorkspaceChannel(ctx context.Context, channel string) context.Context { return context.WithValue(ctx, ctxWsChannel, channel) } func WorkspaceChannelFromCtx(ctx context.Context) string { if v, _ := ctx.Value(ctxWsChannel).(string); v != "" { return v } if rc := store.RunContextFromCtx(ctx); rc != nil { return rc.WorkspaceChannel } return "" } func WithWorkspaceChatID(ctx context.Context, chatID string) context.Context { return context.WithValue(ctx, ctxWsChatID, chatID) } func WorkspaceChatIDFromCtx(ctx context.Context) string { if v, _ := ctx.Value(ctxWsChatID).(string); v != "" { return v } if rc := store.RunContextFromCtx(ctx); rc != nil { return rc.WorkspaceChatID } return "" } // --- Pending team task dispatch (post-turn processing) --- const ctxPendingDispatch toolContextKey = "tool_pending_team_dispatch" // PendingTeamDispatch tracks team tasks created during an agent turn. // After the turn ends, the consumer drains and dispatches them. // Thread-safe: tools may execute in parallel goroutines. type PendingTeamDispatch struct { mu sync.Mutex tasks map[uuid.UUID][]uuid.UUID // teamID → []taskID listed bool // true after list called in this turn teamLock *sync.Mutex // acquired on list, released before post-turn dispatch } func NewPendingTeamDispatch() *PendingTeamDispatch { return &PendingTeamDispatch{tasks: make(map[uuid.UUID][]uuid.UUID)} } // Add records a task created during this turn. func (p *PendingTeamDispatch) Add(teamID, taskID uuid.UUID) { p.mu.Lock() p.tasks[teamID] = append(p.tasks[teamID], taskID) p.mu.Unlock() } // Drain returns all tracked tasks and resets the container. func (p *PendingTeamDispatch) Drain() map[uuid.UUID][]uuid.UUID { p.mu.Lock() defer p.mu.Unlock() out := p.tasks p.tasks = make(map[uuid.UUID][]uuid.UUID) return out } // MarkListed records that list was called in this turn. func (p *PendingTeamDispatch) MarkListed() { p.mu.Lock() p.listed = true p.mu.Unlock() } // HasListed reports whether list was called in this turn. func (p *PendingTeamDispatch) HasListed() bool { p.mu.Lock() defer p.mu.Unlock() return p.listed } // SetTeamLock stores the acquired team create lock so it can be released post-turn. func (p *PendingTeamDispatch) SetTeamLock(m *sync.Mutex) { p.mu.Lock() p.teamLock = m p.mu.Unlock() } // ReleaseTeamLock releases the held team create lock, if any. func (p *PendingTeamDispatch) ReleaseTeamLock() { p.mu.Lock() if p.teamLock != nil { p.teamLock.Unlock() p.teamLock = nil } p.mu.Unlock() } func WithPendingTeamDispatch(ctx context.Context, ptd *PendingTeamDispatch) context.Context { return context.WithValue(ctx, ctxPendingDispatch, ptd) } func PendingTeamDispatchFromCtx(ctx context.Context) *PendingTeamDispatch { v, _ := ctx.Value(ctxPendingDispatch).(*PendingTeamDispatch) return v } // InjectTeamDispatch creates a fresh PendingTeamDispatch context for a direct // loop.Run() call (WS chat.send, HTTP API, etc.) and returns a drain function // that must be called after the run completes. The drain function dispatches // any pending team tasks via the provided PostTurnProcessor. It is safe to // call even if no tasks were created. Pass nil postTurn if not available. func InjectTeamDispatch(ctx context.Context, postTurn PostTurnProcessor) (context.Context, func()) { ptd := NewPendingTeamDispatch() ctx = WithPendingTeamDispatch(ctx, ptd) // Detach from caller's cancel/deadline but keep values (tenant_id, user_id, etc.) // so post-turn dispatch isn't aborted when the HTTP request or WS handler returns. detached := context.WithoutCancel(ctx) drain := func() { ptd.ReleaseTeamLock() if postTurn != nil { for teamID, taskIDs := range ptd.Drain() { if err := postTurn.ProcessPendingTasks(detached, teamID, taskIDs); err != nil { slog.Warn("post_turn: dispatch failed", "team_id", teamID, "error", err) } } } } return ctx, drain } // --- Workstation ID (for tool execution context) --- const ctxWorkstationID toolContextKey = "tool_workstation_id" // WithWorkstationID injects the active workstation UUID string into context. // Used by workstation execution tools (Phase 5) to identify the target backend. func WithWorkstationID(ctx context.Context, id string) context.Context { return context.WithValue(ctx, ctxWorkstationID, id) } // WorkstationIDFromCtx returns the workstation ID from context, or empty string. func WorkstationIDFromCtx(ctx context.Context) string { v, _ := ctx.Value(ctxWorkstationID).(string) return v } // --- Delivered media tracker (write_file → message self-send dedup) --- const ctxDeliveredMedia toolContextKey = "tool_delivered_media" // DeliveredMedia tracks file paths already queued for auto-delivery by write_file. // Injected once per run via WithDeliveredMedia; write_file marks paths, message reads them. // Thread-safe: tools may execute in parallel goroutines. type DeliveredMedia struct { mu sync.Mutex paths map[string]bool } // NewDeliveredMedia creates an empty tracker. func NewDeliveredMedia() *DeliveredMedia { return &DeliveredMedia{paths: make(map[string]bool)} } // Mark records a file path as queued for delivery. func (dm *DeliveredMedia) Mark(path string) { dm.mu.Lock() dm.paths[path] = true dm.mu.Unlock() } // IsDelivered reports whether a file path has already been queued. func (dm *DeliveredMedia) IsDelivered(path string) bool { dm.mu.Lock() defer dm.mu.Unlock() return dm.paths[path] } // WithDeliveredMedia injects a delivered media tracker into context. func WithDeliveredMedia(ctx context.Context, dm *DeliveredMedia) context.Context { return context.WithValue(ctx, ctxDeliveredMedia, dm) } // DeliveredMediaFromCtx returns the delivered media tracker, or nil. func DeliveredMediaFromCtx(ctx context.Context) *DeliveredMedia { v, _ := ctx.Value(ctxDeliveredMedia).(*DeliveredMedia) return v } // --- Run media file paths (for team workspace auto-collect) --- const ctxRunMediaPaths toolContextKey = "tool_run_media_paths" // WithRunMediaPaths stores the absolute file paths of media files received // in the current run. Used by team_tasks to auto-copy files to team workspace. func WithRunMediaPaths(ctx context.Context, paths []string) context.Context { return context.WithValue(ctx, ctxRunMediaPaths, paths) } // RunMediaPathsFromCtx returns media file paths from the current run. func RunMediaPathsFromCtx(ctx context.Context) []string { v, _ := ctx.Value(ctxRunMediaPaths).([]string) return v } const ctxRunMediaNames toolContextKey = "tool_run_media_names" // WithRunMediaNames stores the mapping from media file path to original filename. func WithRunMediaNames(ctx context.Context, names map[string]string) context.Context { return context.WithValue(ctx, ctxRunMediaNames, names) } // RunMediaNamesFromCtx returns the media path → original filename mapping. func RunMediaNamesFromCtx(ctx context.Context) map[string]string { v, _ := ctx.Value(ctxRunMediaNames).(map[string]string) return v } // --- Iteration progress (loop → tools) --- const ctxIterProgress toolContextKey = "tool_iter_progress" // IterationProgress carries the agent loop's current iteration state // so tools can adapt behaviour (e.g. reduce output size) as the budget shrinks. type IterationProgress struct { Current int Max int } func WithIterationProgress(ctx context.Context, p IterationProgress) context.Context { return context.WithValue(ctx, ctxIterProgress, p) } func IterationProgressFromCtx(ctx context.Context) (IterationProgress, bool) { v, ok := ctx.Value(ctxIterProgress).(IterationProgress) return v, ok } // --- Per-agent sandbox config override --- const ctxSandboxCfg toolContextKey = "tool_sandbox_config" func WithSandboxConfig(ctx context.Context, cfg *sandbox.Config) context.Context { return context.WithValue(ctx, ctxSandboxCfg, cfg) } func SandboxConfigFromCtx(ctx context.Context) *sandbox.Config { if v, _ := ctx.Value(ctxSandboxCfg).(*sandbox.Config); v != nil { return v } if rc := store.RunContextFromCtx(ctx); rc != nil { return rc.SandboxCfg } return nil } // --- Per-tenant allowed paths (filesystem tool access beyond workspace) --- const ctxTenantAllowedPaths toolContextKey = "tool_tenant_allowed_paths" // WithTenantAllowedPaths injects tenant-specific allowed path prefixes into context. // These paths extend filesystem tool access beyond the agent's workspace. // Loaded from system_configs['allowed_paths'] per tenant. func WithTenantAllowedPaths(ctx context.Context, paths []string) context.Context { return context.WithValue(ctx, ctxTenantAllowedPaths, paths) } // TenantAllowedPathsFromCtx returns tenant-specific allowed paths from context. // Falls back to RunContext for subagent inheritance. func TenantAllowedPathsFromCtx(ctx context.Context) []string { if v, ok := ctx.Value(ctxTenantAllowedPaths).([]string); ok { return v } if rc := store.RunContextFromCtx(ctx); rc != nil { return rc.TenantAllowedPaths } return nil } const ctxDelegationArtifactInputs toolContextKey = "tool_delegation_artifact_inputs" // WithDelegationArtifactInputs installs the runtime-only host root backing the // logical read-only inputs/ alias for an admitted Agent Link run. func WithDelegationArtifactInputs(ctx context.Context, root string) context.Context { return context.WithValue(ctx, ctxDelegationArtifactInputs, root) } // DelegationArtifactInputsFromCtx returns the runtime-only staged-input root. // It is deliberately not copied into RunContext, prompts, traces, or results. func DelegationArtifactInputsFromCtx(ctx context.Context) string { root, _ := ctx.Value(ctxDelegationArtifactInputs).(string) return root } // IsDelegationArtifactRun reports whether filesystem/media tools are executing // inside an isolated Agent Link artifact exchange. func IsDelegationArtifactRun(ctx context.Context) bool { return DelegationIDFromCtx(ctx) != "" && DelegationArtifactInputsFromCtx(ctx) != "" } func validateDelegationChildRunMode(ctx context.Context, operation, mode string) error { if IsDelegationArtifactRun(ctx) && mode != "sync" { return fmt.Errorf("%s mode %q is not allowed inside an Agent Link artifact run; use mode=\"sync\"", operation, mode) } return nil }