Browse Source

feat(application): 接入运行限制资料 Provider 和子角色传播

将 TaskContextProvider 的 list/resolve 两阶段协议接入现有 ContextSnapshot:框架校验应用、根、Task、uid 与角色归属,按稳定优先级裁剪,验证内容 hash,并沿 ContextRef 权限阻止 task_only 资料转授。

应用子 Agent 只能选择当前角色允许的目标角色,不能临时覆盖模型、Skill 或工具;子 Trace 继承精确 ApplicationRef,固化目标 role hash,并使用父级实际能力与子角色 ToolSet 的交集。

深度、孩子数、并发、迭代与树级预算从已持久化 effective limits 收紧,Brief 新 revision 才重新拉取业务资料,普通 resume、stop recovery 和 rewind 不刷新 Provider。
SamLee 13 hours ago
parent
commit
96a4081326

+ 148 - 0
cyber_agent/application/context.py

@@ -0,0 +1,148 @@
+"""Deterministic TaskContextProvider resolution into existing ContextRefs."""
+
+from __future__ import annotations
+
+from hashlib import sha256
+from typing import Any
+
+from cyber_agent.application.models import canonical_json
+from cyber_agent.application.ports import (
+    ContextMaterial,
+    ContextRequest,
+    ContextResourceDescriptor,
+)
+from cyber_agent.core.context_policy import (
+    MAX_CONTEXT_REFS,
+    MAX_CONTEXT_SNAPSHOT_CHARS,
+    MAX_CONTEXT_SNAPSHOTS_CHARS,
+    ContextPolicyError,
+    ContextSnapshot,
+    create_context_snapshot,
+    ensure_context_access,
+    replace_context_access,
+)
+from cyber_agent.core.task_protocol import RootTaskAnchor, TaskBrief
+
+
+def _descriptor_identity(
+    descriptor: ContextResourceDescriptor,
+) -> tuple[str, str]:
+    return descriptor.source_id, descriptor.source_version
+
+
+def _authorize(
+    request: ContextRequest,
+    descriptor: ContextResourceDescriptor,
+) -> None:
+    expected = (
+        request.application_ref,
+        request.root_trace_id,
+        request.trace_id,
+        request.uid,
+        request.role_id,
+    )
+    actual = (
+        descriptor.application_ref,
+        descriptor.root_trace_id,
+        descriptor.trace_id,
+        descriptor.uid,
+        descriptor.role_id,
+    )
+    if actual != expected:
+        raise ContextPolicyError(
+            "Application context descriptor belongs to another run, task, or role"
+        )
+
+
+async def load_application_context(
+    binding: Any,
+    context: dict[str, Any],
+    request: ContextRequest,
+    *,
+    root_task_anchor: RootTaskAnchor,
+    task_brief: TaskBrief | dict[str, Any] | None,
+    granted_at_sequence: int,
+) -> list[ContextSnapshot]:
+    """Resolve, verify, and atomically add a stable bounded resource set."""
+    provider = binding.services.context_provider
+    if provider is None:
+        return []
+    raw_descriptors = await provider.list_context(request)
+    descriptors = [ContextResourceDescriptor.model_validate(item) for item in raw_descriptors]
+    unique: dict[tuple[str, str], ContextResourceDescriptor] = {}
+    for descriptor in descriptors:
+        _authorize(request, descriptor)
+        key = _descriptor_identity(descriptor)
+        prior = unique.get(key)
+        if prior is not None and prior.content_hash != descriptor.content_hash:
+            raise ContextPolicyError(
+                f"Application context identity has conflicting hashes: {key}"
+            )
+        unique[key] = descriptor
+    ordered = sorted(
+        unique.values(),
+        key=lambda item: (-item.priority, item.source_id, item.source_version),
+    )
+    access = ensure_context_access(context)
+    existing = [
+        ContextSnapshot.model_validate(item)
+        for item in access["snapshots"].values()
+    ]
+    available_slots = max(0, MAX_CONTEXT_REFS - len(existing))
+    current_chars = sum(len(canonical_json(item.content)) for item in existing)
+    selected: list[ContextSnapshot] = []
+    for descriptor in ordered:
+        if len(selected) >= available_slots:
+            break
+        if descriptor.estimated_chars > MAX_CONTEXT_SNAPSHOT_CHARS:
+            continue
+        material = ContextMaterial.model_validate(
+            await provider.resolve_context(request, descriptor)
+        )
+        if material.descriptor != descriptor:
+            raise ContextPolicyError(
+                f"Application context resolver changed descriptor: {descriptor.source_id}"
+            )
+        encoded_content = canonical_json(material.content)
+        actual_hash = sha256(encoded_content.encode("utf-8")).hexdigest()
+        if actual_hash != descriptor.content_hash:
+            raise ContextPolicyError(
+                f"Application context hash mismatch: {descriptor.source_id}"
+            )
+        wrapped = {
+            "application_resource": {
+                "application_ref": request.application_ref.model_dump(mode="json"),
+                "source_id": descriptor.source_id,
+                "source_version": descriptor.source_version,
+                "kind": descriptor.kind,
+                "inheritance": descriptor.inheritance,
+                "authorization": descriptor.authorization,
+            },
+            "content": material.content,
+        }
+        chars = len(canonical_json(wrapped))
+        if chars > MAX_CONTEXT_SNAPSHOT_CHARS:
+            continue
+        if current_chars + chars > MAX_CONTEXT_SNAPSHOTS_CHARS:
+            continue
+        source_trace_id = (
+            f"application:{request.application_ref.application_id}:"
+            f"{descriptor.source_id}@{descriptor.source_version}"
+        )
+        selected.append(create_context_snapshot(
+            kind="application_resource",
+            summary=descriptor.summary,
+            source_trace_id=source_trace_id,
+            root_trace_id=request.root_trace_id,
+            uid=request.uid,
+            content=wrapped,
+            granted_at_sequence=granted_at_sequence,
+        ))
+        current_chars += chars
+    replace_context_access(
+        context,
+        [*existing, *selected],
+        root_task_anchor=root_task_anchor,
+        task_brief=task_brief,
+    )
+    return selected

+ 18 - 1
cyber_agent/core/context_policy.py

@@ -31,7 +31,11 @@ ROOT_TASK_ANCHOR_KEY = "root_task_anchor"
 ROOT_TASK_ANCHOR_HASH_KEY = "root_task_anchor_hash"
 CONTEXT_ACCESS_KEY = "context_access"
 
-ContextSnapshotKind = Literal["task_brief", "reviewed_task_result"]
+ContextSnapshotKind = Literal[
+    "task_brief",
+    "reviewed_task_result",
+    "application_resource",
+]
 
 
 class ContextPolicyError(ValueError):
@@ -440,6 +444,19 @@ def build_child_context_access(
             )
         if snapshot.root_trace_id != root_trace_id or snapshot.uid != uid:
             raise ContextPolicyError("Context reference belongs to another tree or owner")
+        resource_meta = (
+            snapshot.content.get("application_resource")
+            if snapshot.kind == "application_resource"
+            and isinstance(snapshot.content, dict)
+            else None
+        )
+        if (
+            isinstance(resource_meta, dict)
+            and resource_meta.get("inheritance") == "task_only"
+        ):
+            raise ContextPolicyError(
+                f"Application context is task_only and cannot be delegated: {ref.ref_id}"
+            )
         snapshots.append(snapshot.model_copy(
             update={"granted_at_sequence": granted_at_sequence}
         ))

+ 195 - 10
cyber_agent/tools/builtin/subagent.py

@@ -56,6 +56,7 @@ from cyber_agent.core.resource_budget import (
 from cyber_agent.core.memory import compute_memory_identity
 from cyber_agent.core.run_snapshot import (
     RunConfigSnapshotV1,
+    RunConfigSnapshotV2,
     persist_run_config_snapshot,
 )
 from cyber_agent.trace.models import Trace, Messages
@@ -779,6 +780,49 @@ async def _run_agents(
         policy = require_mutable_trace_policy(parent_trace.context)
     except ValueError as exc:
         return {"status": "failed", "error": str(exc)}
+    application_binding = getattr(runner, "application_binding", None)
+    application_ref = parent_trace.context.get("application_ref")
+    target_role_binding = None
+    target_role_id = agent_type
+    if application_ref is not None:
+        if application_binding is None:
+            return {
+                "status": "failed",
+                "error": "Application child creation requires the bound ApplicationRuntime",
+            }
+        if (
+            application_ref
+            != application_binding.application_ref.model_dump(mode="json")
+        ):
+            return {
+                "status": "failed",
+                "error": "Parent ApplicationRef does not match Runner binding",
+            }
+        if skills is not None:
+            return {
+                "status": "failed",
+                "error": "Application roles do not allow per-call skill overrides",
+            }
+        parent_role_id = parent_trace.context.get("application_role_id")
+        try:
+            parent_role = application_binding.role(parent_role_id)
+        except ValueError as exc:
+            return {"status": "failed", "error": str(exc)}
+        allowed_child_roles = parent_role.role.allowed_child_roles
+        if target_role_id is None and len(allowed_child_roles) == 1:
+            target_role_id = allowed_child_roles[0]
+        if target_role_id not in allowed_child_roles:
+            return {
+                "status": "failed",
+                "error": (
+                    f"Application role {parent_role_id} cannot create child role: "
+                    f"{target_role_id}"
+                ),
+            }
+        try:
+            target_role_binding = application_binding.role(target_role_id)
+        except ValueError as exc:
+            return {"status": "failed", "error": str(exc)}
     root_task_anchor = None
     if policy.requires_task_protocol:
         try:
@@ -816,6 +860,12 @@ async def _run_agents(
             )
         except ValueError as exc:
             return {"status": "failed", "error": str(exc)}
+        effective_limits = parent_trace.context.get("effective_run_limits") or {}
+        if effective_limits:
+            max_parallel_children = min(
+                max_parallel_children,
+                int(effective_limits["max_parallel_children"]),
+            )
 
     protocol_state = None
     approved_action = None
@@ -953,6 +1003,17 @@ async def _run_agents(
                 "status": "failed",
                 "error": "continue_from trace Agent mode does not match the current trace",
             }
+        if target_role_binding is not None and (
+            existing.context.get("application_ref") != application_ref
+            or existing.context.get("application_role_id")
+            != target_role_binding.role.role_id
+            or existing.context.get("application_role_hash")
+            != target_role_binding.role_hash
+        ):
+            return {
+                "status": "failed",
+                "error": "continue_from application role binding does not match",
+            }
 
         sub_trace_id = continue_from
         continued = True
@@ -970,12 +1031,18 @@ async def _run_agents(
                 "status": "failed",
                 "error": "continue_from trace lineage does not match the current trace",
             }
-        if child_depth > policy.max_depth:
+        effective_max_depth = int(
+            (parent_trace.context.get("effective_run_limits") or {}).get(
+                "max_depth",
+                policy.max_depth,
+            )
+        )
+        if child_depth > min(policy.max_depth, effective_max_depth):
             return {
                 "status": "failed",
                 "error": (
                     "continue_from trace exceeds the persisted Agent mode depth: "
-                    f"depth={child_depth}, max={policy.max_depth}"
+                    f"depth={child_depth}, max={min(policy.max_depth, effective_max_depth)}"
                 ),
             }
         all_sub_trace_ids = [{"trace_id": sub_trace_id, "mission": mission}]
@@ -1004,7 +1071,8 @@ async def _run_agents(
                 root_task_anchor=root_task_anchor,
                 granted_at_sequence=(existing.last_sequence or 0) + 1,
             )
-            if child_state.get("task_brief") != normalized_dump:
+            brief_changed = child_state.get("task_brief") != normalized_dump
+            if brief_changed:
                 replace_task_brief(
                     child_state,
                     task_briefs[0],
@@ -1012,16 +1080,50 @@ async def _run_agents(
                 )
             child_context[CONTEXT_ACCESS_KEY] = new_context_access
             persist_root_task_anchor(child_context, root_task_anchor)
+            if (
+                target_role_binding is not None
+                and getattr(runner, "context_provider", None) is not None
+                and brief_changed
+            ):
+                from cyber_agent.application.context import load_application_context
+                from cyber_agent.application.ports import ContextRequest
+
+                await load_application_context(
+                    application_binding,
+                    child_context,
+                    ContextRequest(
+                        application_ref=application_binding.application_ref,
+                        root_trace_id=root_trace_id,
+                        trace_id=existing.trace_id,
+                        parent_trace_id=trace_id,
+                        uid=parent_trace.uid,
+                        role_id=target_role_binding.role.role_id,
+                        task_brief=task_briefs[0],
+                        task_brief_revision=child_state["task_brief_version"],
+                        authorized_context_refs=tuple(
+                            task_briefs[0].context_refs
+                        ),
+                    ),
+                    root_task_anchor=root_task_anchor,
+                    task_brief=task_briefs[0],
+                    granted_at_sequence=(existing.last_sequence or 0) + 1,
+                )
             existing.context = child_context
             await store.update_trace(existing.trace_id, context=existing.context)
     else:
         parent_depth, root_trace_id = await _resolve_trace_lineage(store, parent_trace)
-        if parent_depth >= policy.max_depth:
+        effective_max_depth = int(
+            (parent_trace.context.get("effective_run_limits") or {}).get(
+                "max_depth",
+                policy.max_depth,
+            )
+        )
+        if parent_depth >= min(policy.max_depth, effective_max_depth):
             return {
                 "status": "failed",
                 "error": (
                     f"Local Sub-Agent depth limit reached: "
-                    f"depth={parent_depth}, max={policy.max_depth}, "
+                    f"depth={parent_depth}, max={min(policy.max_depth, effective_max_depth)}, "
                     f"mode={policy.mode.value}"
                 ),
             }
@@ -1056,7 +1158,9 @@ async def _run_agents(
                     if task_briefs
                     else task_item
                 )
-                resolved_agent_type = agent_type or ("delegate" if single else "explore")
+                resolved_agent_type = target_role_id or (
+                    "delegate" if single else "explore"
+                )
                 suffix = "delegate" if single else f"explore-{i+1:03d}"
                 stid = generate_sub_trace_id(trace_id, suffix)
                 child_context = apply_policy_to_context({
@@ -1073,6 +1177,47 @@ async def _run_agents(
                     persist_root_task_anchor(child_context, root_task_anchor)
                     if policy.requires_task_progress:
                         initialize_task_progress(child_context["task_protocol"])
+                if target_role_binding is not None:
+                    parent_limits = parent_trace.context["effective_run_limits"]
+                    role_limits = target_role_binding.effective_limits.model_dump(
+                        mode="json"
+                    )
+                    child_limits = {
+                        name: min(parent_limits[name], value)
+                        for name, value in role_limits.items()
+                    }
+                    child_context.update({
+                        "application_ref": application_ref,
+                        "application_role_id": target_role_binding.role.role_id,
+                        "application_role_hash": target_role_binding.role_hash,
+                        "effective_run_limits": child_limits,
+                    })
+                    if getattr(runner, "context_provider", None) is not None:
+                        from cyber_agent.application.context import (
+                            load_application_context,
+                        )
+                        from cyber_agent.application.ports import ContextRequest
+
+                        await load_application_context(
+                            application_binding,
+                            child_context,
+                            ContextRequest(
+                                application_ref=application_binding.application_ref,
+                                root_trace_id=root_trace_id,
+                                trace_id=stid,
+                                parent_trace_id=trace_id,
+                                uid=parent_trace.uid,
+                                role_id=target_role_binding.role.role_id,
+                                task_brief=task_briefs[i],
+                                task_brief_revision=1,
+                                authorized_context_refs=tuple(
+                                    task_briefs[i].context_refs
+                                ),
+                            ),
+                            root_task_anchor=root_task_anchor,
+                            task_brief=task_briefs[i],
+                            granted_at_sequence=0,
+                        )
                 sub_trace = Trace(
                     trace_id=stid,
                     mode="agent",
@@ -1081,7 +1226,11 @@ async def _run_agents(
                     parent_goal_id=goal_id,
                     agent_type=resolved_agent_type,
                     uid=parent_trace.uid,
-                    model=parent_trace.model,
+                    model=(
+                        target_role_binding.role.model
+                        if target_role_binding is not None
+                        else parent_trace.model
+                    ),
                     status="running",
                     context=child_context,
                     created_at=datetime.now(),
@@ -1105,6 +1254,14 @@ async def _run_agents(
                 sub_trace_id = child_records[0]["trace_id"]
 
         child_limit = policy.max_children_per_parent
+        effective_child_limit = (
+            parent_trace.context.get("effective_run_limits") or {}
+        ).get("max_children_per_parent")
+        if effective_child_limit is not None:
+            child_limit = min(
+                child_limit if child_limit is not None else effective_child_limit,
+                int(effective_child_limit),
+            )
         if child_limit is None:
             await create_child_traces()
         else:
@@ -1220,6 +1377,10 @@ async def _run_agents(
             )
         agent_msgs = list(msgs) + [{"role": "user", "content": task_item}]
         allowed_tools = _get_allowed_tools(context, child_depth, policy)
+        if target_role_binding is not None:
+            allowed_tools = sorted(
+                set(allowed_tools or []) & set(target_role_binding.tool_names)
+            )
 
         debug = getattr(runner, 'debug', False)
         agent_label = (agent_type or ("delegate" if single else f"explore-{i+1}"))
@@ -1244,6 +1405,23 @@ async def _run_agents(
             child_execution_mode=child_execution_mode,
             max_parallel_children=max_parallel_children,
         )
+        if target_role_binding is not None:
+            application_binding.configure_run_config(
+                child_config,
+                target_role_binding.role.role_id,
+            )
+            child_config.tools = allowed_tools
+            child_config.context = child_record["context"]
+            child_config.child_execution_mode = child_execution_mode
+            child_config.effective_run_limits = dict(
+                child_record["context"]["effective_run_limits"]
+            )
+            child_config.max_iterations = child_config.effective_run_limits[
+                "max_iterations"
+            ]
+            child_config.max_parallel_children = child_config.effective_run_limits[
+                "max_parallel_children"
+            ]
         # Recursive children are persisted before their coroutine is scheduled so
         # the parent can account for queued work.  Bind the immutable run snapshot
         # before execution; otherwise the first local resume would look exactly
@@ -1257,9 +1435,16 @@ async def _run_agents(
                 if child_config.memory
                 else None
             )
-            child_snapshot = RunConfigSnapshotV1.from_run_config(
-                child_config,
-                memory_identity=child_memory_identity,
+            child_snapshot = (
+                RunConfigSnapshotV2.from_run_config(
+                    child_config,
+                    memory_identity=child_memory_identity,
+                )
+                if target_role_binding is not None
+                else RunConfigSnapshotV1.from_run_config(
+                    child_config,
+                    memory_identity=child_memory_identity,
+                )
             )
             persist_run_config_snapshot(persisted_child.context, child_snapshot)
             await store.update_trace(