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

957 lines
33 KiB
Go

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/<team_id>/)
// 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 ""
}
// OriginChannelFromCtx returns the channel a run ultimately originated from.
// A delegated run is delivered on the internal "delegate" channel while the
// real origin is preserved separately (see buildAgentLinkRunRequest), so team
// scoping and notification routing must follow the origin — addressing them to
// the delivery channel makes the outbound dispatcher drop them. Identity for
// non-delegated runs, where the workspace channel is unset.
func OriginChannelFromCtx(ctx context.Context) string {
if c := WorkspaceChannelFromCtx(ctx); c != "" {
return c
}
return ToolChannelFromCtx(ctx)
}
// OriginChatIDFromCtx is the chat counterpart of OriginChannelFromCtx.
func OriginChatIDFromCtx(ctx context.Context) string {
if c := WorkspaceChatIDFromCtx(ctx); c != "" {
return c
}
return ToolChatIDFromCtx(ctx)
}
// --- 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 (automatic delivery → direct-send dedup) ---
const ctxDeliveredMedia toolContextKey = "tool_delivered_media"
// DeliveredMedia tracks file paths already queued or sent during an agent run.
// Automatic media producers mark paths, and direct-send tools consult the tracker.
// 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
}