Procházet zdrojové kódy

feat(application): 将应用运行时接入真实本地 API 启动路径

增加部署方可信的 module:callable 应用工厂装配器,由 CYBER_AGENT_APPLICATION_FACTORIES 显式注册应用和 Provider 服务;普通服务未配置时继续失败关闭,不允许客户端请求动态导入代码。

默认 API 进程使用同一 TraceStore 和 LLM 构造 ApplicationRuntime 并注入 run_api,参考应用提供可直接启动的本地入口。

补充部署工厂、Application Create HTTP handler、V2 snapshot 以及父角色不暴露孩子工具时的真实 agent 子角色执行测试。
SamLee před 9 hodinami
rodič
revize
d0be07ba93

+ 14 - 2
api_server.py

@@ -26,7 +26,12 @@ import uvicorn
 
 from cyber_agent.trace import FileSystemTraceStore
 from cyber_agent.trace.api import router as api_router, set_trace_store as set_api_trace_store
-from cyber_agent.trace.run_api import router as run_router, experiences_router, set_runner
+from cyber_agent.trace.run_api import (
+    router as run_router,
+    experiences_router,
+    set_application_runtime,
+    set_runner,
+)
 from cyber_agent.trace.websocket import router as ws_router, set_trace_store as set_ws_trace_store
 from cyber_agent.trace.examples_api import router as examples_router
 from cyber_agent.trace.logs_websocket import router as logs_router, setup_websocket_logging
@@ -166,13 +171,20 @@ set_upload_trace_store(trace_store)
 # 如需启用 POST /api/traces(新建/运行/停止/反思),取消以下注释并配置 LLM:
 
 from cyber_agent.core.runner import AgentRunner
+from cyber_agent.application.bootstrap import build_runtime_from_environment
 from cyber_agent.llm import create_openrouter_llm_call
 
+llm_call = create_openrouter_llm_call(model="anthropic/claude-sonnet-4.5")
 runner = AgentRunner(
     trace_store=trace_store,
-    llm_call=create_openrouter_llm_call(model="anthropic/claude-sonnet-4.5"),
+    llm_call=llm_call,
 )
 set_runner(runner)
+application_runtime = build_runtime_from_environment(
+    trace_store=trace_store,
+    llm_call=llm_call,
+)
+set_application_runtime(application_runtime)
 
 
 # ===== 注册路由 =====

+ 70 - 0
cyber_agent/application/bootstrap.py

@@ -0,0 +1,70 @@
+"""Trusted deployment bootstrap for application runtimes.
+
+Factories are configured by the server operator, never by an API request.  Each
+factory returns an object exposing ``application`` and ``services`` (or a
+two-item tuple with those values).
+"""
+
+from __future__ import annotations
+
+import importlib
+import os
+from typing import Any, Callable
+
+from cyber_agent.application.runtime import (
+    ApplicationRegistry,
+    ApplicationRuntime,
+)
+
+
+APPLICATION_FACTORIES_ENV = "CYBER_AGENT_APPLICATION_FACTORIES"
+
+
+def _load_factory(reference: str) -> Callable[[], Any]:
+    module_name, separator, attribute = reference.strip().partition(":")
+    if not separator or not module_name or not attribute:
+        raise ValueError(
+            "Application factory must use the trusted module:callable form"
+        )
+    factory = getattr(importlib.import_module(module_name), attribute, None)
+    if not callable(factory):
+        raise ValueError(f"Application factory is not callable: {reference}")
+    return factory
+
+
+def _components(value: Any) -> tuple[Any, Any]:
+    if isinstance(value, tuple) and len(value) == 2:
+        return value
+    application = getattr(value, "application", None)
+    services = getattr(value, "services", None)
+    if application is None or services is None:
+        raise ValueError(
+            "Application factory must return (application, services) or a "
+            "component object exposing both attributes"
+        )
+    return application, services
+
+
+def build_runtime_from_environment(
+    *,
+    trace_store: Any,
+    llm_call: Callable[..., Any],
+    utility_llm_call: Callable[..., Any] | None = None,
+    environ: dict[str, str] | None = None,
+) -> ApplicationRuntime | None:
+    """Build one shared runtime from deployment-owned factory references."""
+    source = os.environ if environ is None else environ
+    configured = source.get(APPLICATION_FACTORIES_ENV, "").strip()
+    if not configured:
+        return None
+    references = [item.strip() for item in configured.split(",") if item.strip()]
+    registry = ApplicationRegistry()
+    for reference in references:
+        application, services = _components(_load_factory(reference)())
+        registry.register(application, services)
+    return ApplicationRuntime(
+        registry=registry,
+        trace_store=trace_store,
+        llm_call=llm_call,
+        utility_llm_call=utility_llm_call,
+    )

+ 15 - 0
examples/application_reference/api_server.py

@@ -0,0 +1,15 @@
+"""Local API entrypoint with the reference application explicitly enabled."""
+
+from __future__ import annotations
+
+import os
+
+from cyber_agent.application.bootstrap import APPLICATION_FACTORIES_ENV
+
+
+os.environ.setdefault(
+    APPLICATION_FACTORIES_ENV,
+    "examples.application_reference.application:build_reference_components",
+)
+
+from api_server import app  # noqa: E402,F401

+ 59 - 1
tests/test_application_runtime_integration.py

@@ -1,3 +1,4 @@
+import asyncio
 import hashlib
 import json
 import tempfile
@@ -22,6 +23,10 @@ from cyber_agent.application import (
     ToolSpec,
 )
 from cyber_agent.application.models import canonical_json
+from cyber_agent.application.bootstrap import (
+    APPLICATION_FACTORIES_ENV,
+    build_runtime_from_environment,
+)
 from cyber_agent.core.context_policy import (
     ContextPolicyError,
     build_child_context_access,
@@ -41,6 +46,7 @@ from cyber_agent.trace.models import Trace
 from cyber_agent.trace.run_api import (
     CreateRequest,
     _restore_runner_and_config,
+    create_and_run,
     set_application_runtime,
 )
 from cyber_agent.trace.store import FileSystemTraceStore
@@ -186,6 +192,25 @@ class ApplicationRuntimeIntegrationTest(unittest.IsolatedAsyncioTestCase):
         self.assertEqual(1, self.provider.list_calls)
         self.assertEqual(1, self.provider.resolve_calls)
 
+    async def test_application_http_handler_uses_the_bound_runtime(self):
+        with patch.dict("os.environ", {"AGENT_MODE": "recursive"}, clear=False):
+            response = await create_and_run(CreateRequest(
+                messages=[{"role": "user", "content": "Start through HTTP"}],
+                application_id="creative.reference",
+                application_version="0.1.0",
+                uid="http-user",
+                root_task_anchor=ROOT_ANCHOR,
+            ))
+        trace = await self.store.get_trace(response.trace_id)
+        self.assertIsNotNone(trace)
+        snapshot = load_run_config_snapshot(trace.context)
+        self.assertIsInstance(snapshot, RunConfigSnapshotV2)
+        self.assertEqual(
+            self.binding.application_ref.model_dump(mode="json"),
+            snapshot.application_ref,
+        )
+        await asyncio.sleep(0)
+
     async def test_restore_rejects_registry_hash_and_trace_binding_tampering(self):
         trace, base_runner, _config = await self._create_trace()
         changed_registry = ApplicationRegistry()
@@ -380,6 +405,28 @@ class ApplicationCreateRequestTest(unittest.TestCase):
                 application_version="0.1.0",
             )
 
+    def test_deployment_factory_builds_the_http_runtime_registry(self):
+        with tempfile.TemporaryDirectory() as temp_dir:
+            store = FileSystemTraceStore(temp_dir)
+            runtime = build_runtime_from_environment(
+                trace_store=store,
+                llm_call=unused_llm,
+                environ={
+                    APPLICATION_FACTORIES_ENV: (
+                        "examples.application_reference.application:"
+                        "build_reference_components"
+                    ),
+                },
+            )
+            self.assertIsNotNone(runtime)
+            binding = runtime.registry.resolve("application_reference", "1")
+            self.assertEqual("editor", binding.application.root_role)
+            self.assertIsNone(build_runtime_from_environment(
+                trace_store=store,
+                llm_call=unused_llm,
+                environ={},
+            ))
+
 
 def _tool_names(schemas):
     return {item["function"]["name"] for item in schemas or []}
@@ -455,7 +502,18 @@ class ApplicationChildPropagationTest(unittest.IsolatedAsyncioTestCase):
                     ),
                 ),
                 tool_sets=(
-                    ToolSet(tool_set_id="root-tools", tools=declarations),
+                    ToolSet(
+                        tool_set_id="root-tools",
+                        tools=tuple(
+                            item
+                            for item in declarations
+                            if item.tool_name in {
+                                "agent",
+                                "update_task_progress",
+                                "review_task_result",
+                            }
+                        ),
+                    ),
                     ToolSet(
                         tool_set_id="child-tools",
                         tools=tuple(