From 912cfd343dc8a3158ca9d787f795d3c58f44d1e4 Mon Sep 17 00:00:00 2001 From: "zunfei.lzf" Date: Sat, 25 Jul 2026 21:58:14 +0800 Subject: [PATCH 01/37] feat(qwenpaw): adapt worker runtime to 2.0 Change-Id: I9ad741de07f4b7bfbbe3721030df8749546a0def --- .github/workflows/test-integration.yml | 14 +- .../internal/controller/member_reconcile.go | 13 +- .../controller/member_reconcile_test.go | 58 ++++ changelog/current.md | 2 + .../teamharness/adapters/qwenpaw/plugin.py | 13 +- .../qwenpaw/scripts/build-qwenpaw-plugin.rb | 4 + .../adapters/qwenpaw/test_adapter.py | 14 +- .../adapters/qwenpaw/test_package.py | 4 + .../qwenpaw/test_workerflow_package.py | 4 + .../qwenpaw/scripts/build-qwenpaw-plugin.rb | 4 + qwenpaw/Dockerfile | 7 +- qwenpaw/pyproject.toml | 5 +- .../patch-qwenpaw-defer-mcp-startup.py | 112 ------- qwenpaw/src/matrix/channel.py | 2 +- qwenpaw/tests/test_defer_mcp_startup_patch.py | 80 ----- qwenpaw/tests/test_integration_workflow.py | 38 +++ qwenpaw/tests/test_matrix_overlay.py | 12 +- .../tests/test_qwenpaw2_runtime_contract.py | 306 ++++++++++++++++++ .../tests/test_qwenpaw2_startup_contract.py | 9 + qwenpaw/tests/test_runtime_dependencies.py | 20 ++ qwenpaw/tests/test_worker_lifecycle.py | 13 + 21 files changed, 519 insertions(+), 215 deletions(-) delete mode 100644 qwenpaw/scripts/patch-qwenpaw-defer-mcp-startup.py delete mode 100644 qwenpaw/tests/test_defer_mcp_startup_patch.py create mode 100644 qwenpaw/tests/test_integration_workflow.py create mode 100644 qwenpaw/tests/test_qwenpaw2_runtime_contract.py create mode 100644 qwenpaw/tests/test_qwenpaw2_startup_contract.py create mode 100644 qwenpaw/tests/test_runtime_dependencies.py diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml index 9a35f3fd6..146548fa2 100644 --- a/.github/workflows/test-integration.yml +++ b/.github/workflows/test-integration.yml @@ -13,7 +13,10 @@ on: - 'install/**' - 'shared/**' - 'copaw/**' + - 'qwenpaw/**' - 'hermes/**' + - 'plugins/teamharness/adapters/qwenpaw/**' + - 'plugins/workerflow/adapters/qwenpaw/**' - 'Makefile' - '.github/workflows/test-integration.yml' push: @@ -30,7 +33,10 @@ on: - 'install/**' - 'shared/**' - 'copaw/**' + - 'qwenpaw/**' - 'hermes/**' + - 'plugins/teamharness/adapters/qwenpaw/**' + - 'plugins/workerflow/adapters/qwenpaw/**' - 'Makefile' workflow_dispatch: inputs: @@ -50,6 +56,7 @@ on: - both - openclaw - copaw + - qwenpaw - hermes default: 'both' model: @@ -72,6 +79,7 @@ env: SHARD_B_TESTS: "14" SHARD_C_TESTS: "15 17 18 19 20 22 23 24 25 100" SHARD_D_TESTS: "21" + SHARD_QWENPAW_TESTS: "26" NON_GITHUB_TESTS: "01 02 03 04 05 06 14 15 17 18 19 20 21 22 23 24 25 100" jobs: @@ -125,6 +133,10 @@ jobs: {"shard":"llm-interaction-2","filter_env":"SHARD_B_TESTS","manager_runtime":"copaw","worker_runtime":"copaw","requires_secret":true}, {"shard":"controller-cr","filter_env":"SHARD_C_TESTS","manager_runtime":"copaw","worker_runtime":"copaw","requires_secret":false}, {"shard":"controller-cr-2","filter_env":"SHARD_D_TESTS","manager_runtime":"copaw","worker_runtime":"copaw","requires_secret":false}, + {"shard":"llm-interaction","filter_env":"SHARD_A_TESTS","manager_runtime":"copaw","worker_runtime":"qwenpaw","requires_secret":true}, + {"shard":"llm-interaction-2","filter_env":"SHARD_B_TESTS","manager_runtime":"copaw","worker_runtime":"qwenpaw","requires_secret":true}, + {"shard":"controller-cr","filter_env":"SHARD_C_TESTS","manager_runtime":"copaw","worker_runtime":"qwenpaw","requires_secret":false}, + {"shard":"qwenpaw-teamharness","filter_env":"SHARD_QWENPAW_TESTS","manager_runtime":"copaw","worker_runtime":"qwenpaw","requires_secret":true}, {"shard":"llm-interaction","filter_env":"SHARD_A_TESTS","manager_runtime":"copaw","worker_runtime":"hermes","requires_secret":true}, {"shard":"llm-interaction-2","filter_env":"SHARD_B_TESTS","manager_runtime":"copaw","worker_runtime":"hermes","requires_secret":true}, {"shard":"controller-cr","filter_env":"SHARD_C_TESTS","manager_runtime":"copaw","worker_runtime":"hermes","requires_secret":false} @@ -230,7 +242,7 @@ jobs: strategy: fail-fast: false matrix: - target: [embedded, manager, manager-copaw, worker, copaw-worker, hermes-worker] + target: [embedded, manager, manager-copaw, worker, copaw-worker, qwenpaw-worker, hermes-worker] steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/agentteams-controller/internal/controller/member_reconcile.go b/agentteams-controller/internal/controller/member_reconcile.go index 0cacc174c..21faeb88d 100644 --- a/agentteams-controller/internal/controller/member_reconcile.go +++ b/agentteams-controller/internal/controller/member_reconcile.go @@ -753,7 +753,15 @@ func createMemberContainer(ctx context.Context, d MemberDeps, m MemberContext, s } createReq.AuthToken = token - if err := waitForScopedWorkerConfig(ctx, m.Name, workerEnv); err != nil { + configOwner := m.Name + configKey := "agents/" + configOwner + "/openclaw.json" + if backend.ResolveRuntime(m.Spec.Runtime, d.DefaultRuntime) == backend.RuntimeQwenPaw { + if m.RuntimeName != "" { + configOwner = m.RuntimeName + } + configKey = "agents/" + configOwner + "/runtime/runtime.yaml" + } + if err := waitForScopedWorkerConfig(ctx, m.Name, configKey, workerEnv); err != nil { return reconcile.Result{}, fmt.Errorf("worker scoped storage config is not readable: %w", err) } } @@ -767,7 +775,7 @@ func createMemberContainer(ctx context.Context, d MemberDeps, m MemberContext, s return reconcile.Result{}, nil } -func waitForScopedWorkerConfig(ctx context.Context, workerName string, workerEnv map[string]string) error { +func waitForScopedWorkerConfig(ctx context.Context, workerName, key string, workerEnv map[string]string) error { accessKey := strings.TrimSpace(workerEnv["AGENTTEAMS_FS_ACCESS_KEY"]) secretKey := strings.TrimSpace(workerEnv["AGENTTEAMS_FS_SECRET_KEY"]) if accessKey == "" || secretKey == "" { @@ -793,7 +801,6 @@ func waitForScopedWorkerConfig(ctx context.Context, workerName string, workerEnv StoragePrefix: storagePrefix, }) - key := "agents/" + workerName + "/openclaw.json" var lastErr error deadline := time.Now().Add(2 * time.Minute) for { diff --git a/agentteams-controller/internal/controller/member_reconcile_test.go b/agentteams-controller/internal/controller/member_reconcile_test.go index 786506a21..9d325d247 100644 --- a/agentteams-controller/internal/controller/member_reconcile_test.go +++ b/agentteams-controller/internal/controller/member_reconcile_test.go @@ -3,8 +3,11 @@ package controller import ( "context" "errors" + "os" + "path/filepath" "strings" "testing" + "time" v1beta1 "github.com/agentscope-ai/AgentTeams/agentteams-controller/api/v1beta1" "github.com/agentscope-ai/AgentTeams/agentteams-controller/internal/backend" @@ -131,6 +134,61 @@ func TestCreateMemberContainerAddsDockerHostGateway(t *testing.T) { } } +func TestCreateMemberContainerQwenPawWaitsForRuntimeConfig(t *testing.T) { + binDir := t.TempDir() + mcPath := filepath.Join(binDir, "mc") + mcScript := `#!/bin/sh +if [ "$1" = "alias" ]; then + exit 0 +fi +if [ "$1" = "stat" ] && [ "$2" = "local/bucket/agents/alice-runtime/runtime/runtime.yaml" ]; then + exit 0 +fi +echo "Object does not exist" >&2 +exit 1 +` + if err := os.WriteFile(mcPath, []byte(mcScript), 0755); err != nil { + t.Fatalf("write fake mc: %v", err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("AGENTTEAMS_FS_ENDPOINT", "http://minio.test") + + wb := mocks.NewMockWorkerBackend() + wb.NameOverride = "docker" + envBuilder := mocks.NewMockEnvBuilder() + envBuilder.BuildFn = func(workerName string, _ *service.WorkerProvisionResult) map[string]string { + return map[string]string{ + "AGENTTEAMS_WORKER_NAME": workerName, + "AGENTTEAMS_FS_ACCESS_KEY": "access-key", + "AGENTTEAMS_FS_SECRET_KEY": "secret-key", + "AGENTTEAMS_FS_BUCKET": "bucket", + "AGENTTEAMS_STORAGE_PREFIX": "local/bucket", + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := createMemberContainer(ctx, MemberDeps{ + Provisioner: mocks.NewMockProvisioner(), + EnvBuilder: envBuilder, + }, MemberContext{ + Name: "alice", + RuntimeName: "alice-runtime", + Spec: v1beta1.WorkerSpec{ + Image: "qwenpaw:latest", + Runtime: backend.RuntimeQwenPaw, + }, + }, &MemberState{ + ProvResult: &service.WorkerProvisionResult{MatrixToken: "token"}, + }, wb) + if err != nil { + t.Fatalf("createMemberContainer failed: %v", err) + } + if _, ok := wb.LastCreateReq(); !ok { + t.Fatal("expected backend Create to be called") + } +} + func TestCreateMemberContainerDoesNotAddDockerHostGatewayForK8s(t *testing.T) { wb := mocks.NewMockWorkerBackend() wb.NameOverride = "k8s" diff --git a/changelog/current.md b/changelog/current.md index 6436a6d7d..f457d29c8 100644 --- a/changelog/current.md +++ b/changelog/current.md @@ -6,6 +6,7 @@ Record image-affecting changes to `manager/`, `worker/`, `copaw/`, `hermes/`, `o **Bug Fixes** +- **QwenPaw local startup readiness**: Wait for the runtime-owned `runtime/runtime.yaml` object before creating a local Docker QwenPaw Worker. - **Team Worker room boundary convergence**: Remove Manager again after standalone Worker infrastructure reconciliation restores regular Team Worker personal-room membership. ([b5b0add](https://github.com/agentscope-ai/AgentTeams/commit/b5b0add)) - **Team Worker reference enforcement**: Keep referenced Worker CRs protected during direct deletion and reject Team API members whose required role is empty. ([d96f1ed](https://github.com/agentscope-ai/AgentTeams/commit/d96f1ed)) - **Team Worker room membership**: Force Manager out of regular Team Worker personal rooms when equal Matrix power levels prevent a normal kick. ([43545c2](https://github.com/agentscope-ai/AgentTeams/commit/43545c2)) @@ -15,6 +16,7 @@ Record image-affecting changes to `manager/`, `worker/`, `copaw/`, `hermes/`, `o **Branding and Compatibility** +- **QwenPaw 2.0 runtime compatibility**: Adapt the QwenPaw Worker image, Matrix overlay, and native plugins to QwenPaw 2.0.0.post3 startup and schema contracts. - **Complete AgentTeams runtime rename**: Rename installer and Helm entrypoints, the controller Go module and CLI, and container filesystem paths to AgentTeams while preserving thin compatibility aliases and upgrade migration for existing HiClaw installations. ([3121f5f](https://github.com/agentscope-ai/AgentTeams/commit/3121f5f)) - **Hard-cut AgentTeams naming**: Remove retired-brand installer wrappers, environment fallbacks, CLI aliases, Helm naming branches, runtime path migrations, and active source paths so fresh AgentTeams deployments use one canonical contract end to end. ([d20e606](https://github.com/agentscope-ai/AgentTeams/commit/d20e606617edefbbc42c28c1201c5629fa73fd88)) - **Hard-cut Team and Worker resources**: Make Worker CRs the sole owners of runtime configuration and lifecycle, make Team CRs reference existing Workers through `spec.workerMembers`, and remove inline-member, registry, migration, and dependent-script compatibility paths. ([b3cf360](https://github.com/agentscope-ai/AgentTeams/commit/b3cf360)) diff --git a/plugins/teamharness/adapters/qwenpaw/plugin.py b/plugins/teamharness/adapters/qwenpaw/plugin.py index 4ed8c80a4..016a39c82 100644 --- a/plugins/teamharness/adapters/qwenpaw/plugin.py +++ b/plugins/teamharness/adapters/qwenpaw/plugin.py @@ -385,12 +385,14 @@ def sanitize_text(text: str) -> str: def _qwenpaw_config_path() -> Optional[Path]: + working_dir = _qwenpaw_working_dir() + if working_dir is not None and os.getenv("QWENPAW_WORKING_DIR", "").strip(): + return working_dir / "config.json" try: from qwenpaw.config import get_config_path return Path(get_config_path()) except Exception: - working_dir = _qwenpaw_working_dir() return working_dir / "config.json" if working_dir is not None else None @@ -516,13 +518,14 @@ def sanitize_tool_result(result: Any) -> Dict[str, Any]: def _iter_qwenpaw_agents() -> List[tuple[str, Path]]: + workspace = os.getenv("QWENPAW_WORKSPACE_DIR", "").strip() + if workspace: + return [("default", Path(workspace).expanduser())] try: from qwenpaw.config.utils import load_config except ImportError: - workspace = os.getenv("QWENPAW_WORKSPACE_DIR", "").strip() - if not workspace: - qwenpaw_dir = os.getenv("QWENPAW_WORKING_DIR", "").strip() - workspace = str(Path(qwenpaw_dir) / "workspaces" / "default") if qwenpaw_dir else "" + qwenpaw_dir = os.getenv("QWENPAW_WORKING_DIR", "").strip() + workspace = str(Path(qwenpaw_dir) / "workspaces" / "default") if qwenpaw_dir else "" return [("default", Path(workspace))] if workspace else [] root = load_config() diff --git a/plugins/teamharness/adapters/qwenpaw/scripts/build-qwenpaw-plugin.rb b/plugins/teamharness/adapters/qwenpaw/scripts/build-qwenpaw-plugin.rb index 91993903d..8226b691a 100755 --- a/plugins/teamharness/adapters/qwenpaw/scripts/build-qwenpaw-plugin.rb +++ b/plugins/teamharness/adapters/qwenpaw/scripts/build-qwenpaw-plugin.rb @@ -107,6 +107,10 @@ def zip_dir(root, package_name, out_path) }, "dependencies" => [], "min_version" => "1.1.7", + "qwenpaw_version" => { + "min" => "2.0.0", + "max" => "2.1.0" + }, "meta" => { "category" => "teamharness", "features" => [ diff --git a/plugins/tests/teamharness/adapters/qwenpaw/test_adapter.py b/plugins/tests/teamharness/adapters/qwenpaw/test_adapter.py index 47177d588..cf135f4d0 100644 --- a/plugins/tests/teamharness/adapters/qwenpaw/test_adapter.py +++ b/plugins/tests/teamharness/adapters/qwenpaw/test_adapter.py @@ -14,11 +14,23 @@ @pytest.fixture(autouse=True) -def _clear_agent_workspace_env(): +def _isolate_qwenpaw_runtime(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + qwenpaw_modules = { + name: module + for name, module in sys.modules.items() + if name == "qwenpaw" or name.startswith("qwenpaw.") + } + for name in qwenpaw_modules: + sys.modules.pop(name, None) original = os.environ.pop("AGENT_WORKSPACE", None) + monkeypatch.setenv("QWENPAW_WORKING_DIR", str(tmp_path / ".qwenpaw")) try: yield finally: + for name in tuple(sys.modules): + if name == "qwenpaw" or name.startswith("qwenpaw."): + sys.modules.pop(name, None) + sys.modules.update(qwenpaw_modules) if original is None: os.environ.pop("AGENT_WORKSPACE", None) else: diff --git a/plugins/tests/teamharness/adapters/qwenpaw/test_package.py b/plugins/tests/teamharness/adapters/qwenpaw/test_package.py index be1dc3385..001e9fab3 100644 --- a/plugins/tests/teamharness/adapters/qwenpaw/test_package.py +++ b/plugins/tests/teamharness/adapters/qwenpaw/test_package.py @@ -78,6 +78,10 @@ def test_build_qwenpaw_native_plugin_package(tmp_path: Path) -> None: assert manifest["id"] == "teamharness" assert manifest["version"] == version assert manifest["entry"]["backend"] == "plugin.py" + assert manifest["qwenpaw_version"] == { + "min": "2.0.0", + "max": "2.1.0", + } assert "periodic-sync" not in manifest["meta"]["features"] probe = subprocess.run( diff --git a/plugins/tests/workerflow/adapters/qwenpaw/test_workerflow_package.py b/plugins/tests/workerflow/adapters/qwenpaw/test_workerflow_package.py index ca1ff08d2..ad3d29464 100644 --- a/plugins/tests/workerflow/adapters/qwenpaw/test_workerflow_package.py +++ b/plugins/tests/workerflow/adapters/qwenpaw/test_workerflow_package.py @@ -62,4 +62,8 @@ def test_build_qwenpaw_native_workerflow_plugin_package(tmp_path: Path) -> None: assert manifest["id"] == "workerflow" assert manifest["version"] == version assert manifest["entry"]["backend"] == "plugin.py" + assert manifest["qwenpaw_version"] == { + "min": "2.0.0", + "max": "2.1.0", + } assert "workerflow-mcp" in manifest["meta"]["features"] diff --git a/plugins/workerflow/adapters/qwenpaw/scripts/build-qwenpaw-plugin.rb b/plugins/workerflow/adapters/qwenpaw/scripts/build-qwenpaw-plugin.rb index d407d11eb..cedbc8857 100755 --- a/plugins/workerflow/adapters/qwenpaw/scripts/build-qwenpaw-plugin.rb +++ b/plugins/workerflow/adapters/qwenpaw/scripts/build-qwenpaw-plugin.rb @@ -106,6 +106,10 @@ def zip_dir(root, package_name, out_path) }, "dependencies" => [], "min_version" => "1.1.7", + "qwenpaw_version" => { + "min" => "2.0.0", + "max" => "2.1.0" + }, "meta" => { "category" => "workerflow", "features" => [ diff --git a/qwenpaw/Dockerfile b/qwenpaw/Dockerfile index 7b1c7bdc0..2b6276c2d 100644 --- a/qwenpaw/Dockerfile +++ b/qwenpaw/Dockerfile @@ -24,7 +24,7 @@ FROM ${HIGRESS_REGISTRY}/higress/python:3.11-slim ARG APT_MIRROR= ARG PIP_INDEX_URL=https://pypi.org/simple/ ARG PIP_TIMEOUT=300 -ARG QWENPAW_PIP_SPEC=qwenpaw==1.1.11 +ARG QWENPAW_PIP_SPEC=qwenpaw==2.0.0.post3 ARG NPM_REGISTRY=https://registry.npmjs.org/ RUN if [ -n "${APT_MIRROR}" ]; then \ @@ -112,11 +112,6 @@ RUN /opt/venv/qwenpaw/bin/python /tmp/install-builtin-qwenpaw-plugins.py \ /opt/agentteams/plugins/workerflow-qwenpaw.zip \ && rm -f /tmp/install-builtin-qwenpaw-plugins.py -# Enable QwenPaw's native deferred MCP startup for app workspaces. -COPY qwenpaw/scripts/patch-qwenpaw-defer-mcp-startup.py /tmp/patch-qwenpaw-defer-mcp-startup.py -RUN /opt/venv/qwenpaw/bin/python /tmp/patch-qwenpaw-defer-mcp-startup.py && \ - rm -f /tmp/patch-qwenpaw-defer-mcp-startup.py - COPY --chmod=755 qwenpaw/scripts/qwenpaw-worker-entrypoint.sh /opt/agentteams/scripts/qwenpaw-worker-entrypoint.sh COPY --chmod=755 qwenpaw/scripts/qwenpaw-teamharness-plugin-reload.sh /opt/agentteams/scripts/qwenpaw-teamharness-plugin-reload.sh COPY --from=shared . /opt/agentteams/scripts/lib/ diff --git a/qwenpaw/pyproject.toml b/qwenpaw/pyproject.toml index e6385ba57..7e2d7d462 100644 --- a/qwenpaw/pyproject.toml +++ b/qwenpaw/pyproject.toml @@ -8,9 +8,10 @@ version = "0.1.0" description = "AgentTeams Worker runtime based on QwenPaw" readme = "README.md" license = { text = "Apache-2.0" } -requires-python = ">=3.10" +requires-python = ">=3.11,<3.14" dependencies = [ - "qwenpaw==1.1.11", + "qwenpaw==2.0.0.post3", + "agent-client-protocol>=0.9.0,<0.11.0", "markdown-it-py>=3.0", "linkify-it-py>=2.0", "pyyaml>=6.0", diff --git a/qwenpaw/scripts/patch-qwenpaw-defer-mcp-startup.py b/qwenpaw/scripts/patch-qwenpaw-defer-mcp-startup.py deleted file mode 100644 index fbc62df70..000000000 --- a/qwenpaw/scripts/patch-qwenpaw-defer-mcp-startup.py +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env python3 -"""Enable QwenPaw's native deferred MCP startup for app workspaces.""" - -from __future__ import annotations - -from pathlib import Path -import sys - - -MULTI_AGENT_MANAGER_PATCHES = ( - ( - """ instance = Workspace( - agent_id=agent_id, - workspace_dir=agent_ref.workspace_dir, - ) -""", - """ instance = Workspace( - agent_id=agent_id, - workspace_dir=agent_ref.workspace_dir, - defer_mcp_startup=True, - ) -""", - ), - ( - """ new_instance = Workspace( - agent_id=agent_id, - workspace_dir=agent_ref.workspace_dir, - ) -""", - """ new_instance = Workspace( - agent_id=agent_id, - workspace_dir=agent_ref.workspace_dir, - defer_mcp_startup=True, - ) -""", - ), -) - - -SERVICE_FACTORIES_PATCHES = ( - ( - """ mcp.init_from_config_background(ws._config.mcp) -""", - """ mcp.init_from_config_background( - ws._config.mcp, - timeout=60.0, - ) -""", - ), -) - - -def _apply_patches(source: str, patches, component: str) -> str: - patched = source - for before, after in patches: - if after in patched: - continue - if before not in patched: - raise RuntimeError( - f"QwenPaw {component} shape changed; " - "cannot enable deferred MCP startup safely", - ) - patched = patched.replace(before, after, 1) - return patched - - -def patch_multi_agent_manager_source(source: str) -> str: - return _apply_patches( - source, - MULTI_AGENT_MANAGER_PATCHES, - "MultiAgentManager", - ) - - -def patch_service_factories_source(source: str) -> str: - return _apply_patches( - source, - SERVICE_FACTORIES_PATCHES, - "workspace service factories", - ) - - -def patch_source(source: str) -> str: - return patch_multi_agent_manager_source(source) - - -def _patch_file(target: Path, patcher) -> None: - source = target.read_text(encoding="utf-8") - patched = patcher(source) - if patched != source: - target.write_text(patched, encoding="utf-8") - - -def main() -> int: - app_dir = ( - Path(sys.prefix) - / "lib" - / "python3.11" - / "site-packages" - / "qwenpaw" - / "app" - ) - _patch_file(app_dir / "multi_agent_manager.py", patch_source) - _patch_file( - app_dir / "workspace" / "service_factories.py", - patch_service_factories_source, - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/qwenpaw/src/matrix/channel.py b/qwenpaw/src/matrix/channel.py index 9582f414a..f281e1d0f 100644 --- a/qwenpaw/src/matrix/channel.py +++ b/qwenpaw/src/matrix/channel.py @@ -66,7 +66,7 @@ WhoamiResponse, ) -from agentscope_runtime.engine.schemas.agent_schemas import ( +from qwenpaw.schemas import ( AudioContent, ContentType, FileContent, diff --git a/qwenpaw/tests/test_defer_mcp_startup_patch.py b/qwenpaw/tests/test_defer_mcp_startup_patch.py deleted file mode 100644 index ddfb83b96..000000000 --- a/qwenpaw/tests/test_defer_mcp_startup_patch.py +++ /dev/null @@ -1,80 +0,0 @@ -import importlib.util -from pathlib import Path - -import pytest - - -ROOT = Path(__file__).resolve().parents[1] -PATCH_SCRIPT = ROOT / "scripts" / "patch-qwenpaw-defer-mcp-startup.py" - - -def _load_patch_script(): - spec = importlib.util.spec_from_file_location( - "_patch_qwenpaw_defer_mcp_startup", - PATCH_SCRIPT, - ) - module = importlib.util.module_from_spec(spec) - assert spec.loader is not None - spec.loader.exec_module(module) - return module - - -def test_defer_mcp_startup_patch_is_applied_during_image_build() -> None: - dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") - - assert "patch-qwenpaw-defer-mcp-startup.py" in dockerfile - assert "/opt/venv/qwenpaw/bin/python /tmp/patch-qwenpaw-defer-mcp-startup.py" in dockerfile - assert dockerfile.index("install-builtin-qwenpaw-plugins.py") < dockerfile.index( - "patch-qwenpaw-defer-mcp-startup.py", - ) - - -def test_defer_mcp_startup_patch_enables_native_workspace_flag() -> None: - module = _load_patch_script() - source = """ - instance = Workspace( - agent_id=agent_id, - workspace_dir=agent_ref.workspace_dir, - ) - - new_instance = Workspace( - agent_id=agent_id, - workspace_dir=agent_ref.workspace_dir, - ) -""" - - patched = module.patch_source(source) - - assert patched.count("defer_mcp_startup=True") == 2 - assert patched == module.patch_source(patched) - - -def test_defer_mcp_startup_patch_preserves_mcp_connect_timeout() -> None: - module = _load_patch_script() - source = """ - if getattr(ws, "defer_mcp_startup", False): - mcp.init_from_config_background(ws._config.mcp) - logger.debug( - f"MCP initialization deferred for agent: {ws.agent_id}", - ) -""" - - patched = module.patch_service_factories_source(source) - - assert "mcp.init_from_config_background(" in patched - assert "timeout=60.0" in patched - assert patched == module.patch_service_factories_source(patched) - - -def test_defer_mcp_startup_patch_fails_when_qwenpaw_shape_changes() -> None: - module = _load_patch_script() - - with pytest.raises(RuntimeError, match="shape changed"): - module.patch_source("instance = Workspace(agent_id=agent_id)\n") - - -def test_defer_mcp_startup_patch_fails_when_service_factory_changes() -> None: - module = _load_patch_script() - - with pytest.raises(RuntimeError, match="shape changed"): - module.patch_service_factories_source("mcp.init_from_config(config)\n") diff --git a/qwenpaw/tests/test_integration_workflow.py b/qwenpaw/tests/test_integration_workflow.py new file mode 100644 index 000000000..4b54bdcff --- /dev/null +++ b/qwenpaw/tests/test_integration_workflow.py @@ -0,0 +1,38 @@ +from pathlib import Path + +import yaml + + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = ROOT / ".github" / "workflows" / "test-integration.yml" + + +def test_integration_workflow_runs_qwenpaw_like_copaw() -> None: + workflow = yaml.load( + WORKFLOW.read_text(encoding="utf-8"), + Loader=yaml.BaseLoader, + ) + + pull_request_paths = workflow["on"]["pull_request"]["paths"] + push_paths = workflow["on"]["push"]["paths"] + assert "qwenpaw/**" in pull_request_paths + assert "qwenpaw/**" in push_paths + + build_targets = workflow["jobs"]["build-images"]["strategy"]["matrix"]["target"] + assert "qwenpaw-worker" in build_targets + + matrix_step = next( + step + for step in workflow["jobs"]["detect-changes"]["steps"] + if step.get("id") == "test-matrix" + ) + matrix_script = matrix_step["run"] + assert matrix_script.count('"worker_runtime":"qwenpaw"') == 4 + assert ( + '"shard":"qwenpaw-teamharness","filter_env":"SHARD_QWENPAW_TESTS",' + '"manager_runtime":"copaw","worker_runtime":"qwenpaw",' + '"requires_secret":true' + ) in matrix_script + assert workflow["jobs"]["integration-tests"]["strategy"]["matrix"] == ( + "${{ fromJSON(needs.detect-changes.outputs.test_matrix) }}" + ) diff --git a/qwenpaw/tests/test_matrix_overlay.py b/qwenpaw/tests/test_matrix_overlay.py index 483aaa3f7..5185950a2 100644 --- a/qwenpaw/tests/test_matrix_overlay.py +++ b/qwenpaw/tests/test_matrix_overlay.py @@ -46,6 +46,13 @@ def test_matrix_overlay_is_valid_python() -> None: ast.parse(_overlay_source(), filename=str(OVERLAY)) +def test_matrix_overlay_uses_qwenpaw_2_streaming_schema() -> None: + source = _overlay_source() + + assert "from qwenpaw.schemas import" in source + assert "agentscope_runtime.engine.schemas.agent_schemas" not in source + + def test_matrix_overlay_supports_streaming_reasoning_hooks() -> None: source = _overlay_source() @@ -155,11 +162,8 @@ def _content_init(self, **kwargs): SyncError=_Dummy, WhoamiResponse=_Dummy, ) - _install_module("agentscope_runtime") - _install_module("agentscope_runtime.engine") - _install_module("agentscope_runtime.engine.schemas") _install_module( - "agentscope_runtime.engine.schemas.agent_schemas", + "qwenpaw.schemas", AudioContent=content_class, ContentType=_ContentType, FileContent=content_class, diff --git a/qwenpaw/tests/test_qwenpaw2_runtime_contract.py b/qwenpaw/tests/test_qwenpaw2_runtime_contract.py new file mode 100644 index 000000000..c8a06d14f --- /dev/null +++ b/qwenpaw/tests/test_qwenpaw2_runtime_contract.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import sys +import textwrap + + +ROOT = Path(__file__).resolve().parents[1] + + +def _run_qwenpaw2(tmp_path: Path, script: str) -> None: + home = tmp_path / "home" + working_dir = home / ".qwenpaw" + env = os.environ.copy() + env.update( + { + "HOME": str(home), + "PYTHONPATH": str(ROOT / "src"), + "QWENPAW_WORKING_DIR": str(working_dir), + "QWENPAW_SECRET_DIR": str(home / ".qwenpaw.secret"), + "QWENPAW_RUNNING_IN_CONTAINER": "true", + } + ) + result = subprocess.run( + [sys.executable, "-c", textwrap.dedent(script), str(tmp_path)], + check=False, + env=env, + capture_output=True, + text=True, + ) + assert result.returncode == 0, ( + f"QwenPaw 2 contract subprocess failed\n" + f"stdout:\n{result.stdout}\n" + f"stderr:\n{result.stderr}" + ) + + +def test_qwenpaw2_applies_real_model_provider_from_runtime_contract( + tmp_path: Path, +) -> None: + _run_qwenpaw2( + tmp_path, + """ + import os + from pathlib import Path + import sys + + from qwenpaw.providers.provider_manager import ProviderManager + from qwenpaw_worker.config import WorkerConfig + from qwenpaw_worker.update import MemberRuntimeConfig, RuntimeUpdater + + root = Path(sys.argv[1]) + os.environ["BAILIAN_API_KEY"] = "test-bailian-key" + config = WorkerConfig( + worker_name="worker-a", + fs_endpoint="unused", + fs_access_key="unused", + fs_secret_key="unused", + install_dir=root / "agents", + ) + + class NoopPackageManager: + def apply(self, _config): + return None + + RuntimeUpdater( + config=config, + package_manager=NoopPackageManager(), + ).apply_once( + runtime_config=MemberRuntimeConfig( + path=config.runtime_config_path, + raw={ + "metadata": {"generation": "1"}, + "member": {"runtime": "qwenpaw"}, + "desired": { + "model": { + "providerId": "bailian", + "providerName": "Alibaba Cloud Model Studio", + "model": "qwen3.6-plus", + "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "apiKeyEnv": "BAILIAN_API_KEY", + }, + }, + }, + ), + reapply_adapter=False, + ) + + manager = ProviderManager.get_instance() + provider = manager.custom_providers["bailian"] + assert manager.active_model.provider_id == "bailian" + assert manager.active_model.model == "qwen3.6-plus" + assert provider.base_url == "https://dashscope.aliyuncs.com/compatible-mode/v1" + assert provider.api_key == "test-bailian-key" + assert provider.chat_model == "OpenAIChatModel" + """, + ) + + +def test_qwenpaw2_loads_runtime_mcp_and_agent_package_skills( + tmp_path: Path, +) -> None: + _run_qwenpaw2( + tmp_path, + """ + import json + import os + from pathlib import Path + import sys + + from qwenpaw.agents.skill_system.registry import resolve_effective_skills + from qwenpaw.config.config import load_agent_config + from qwenpaw_worker.config import WorkerConfig + from qwenpaw_worker.update import MemberRuntimeConfig, RuntimeUpdater + + root = Path(sys.argv[1]) + package = root / "agent-package" + skill = package / "skills" / "hot-skill" + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text( + "---\\nname: hot-skill\\ndescription: runtime contract\\n---\\n# Hot Skill\\n", + encoding="utf-8", + ) + (package / "mcp.json").write_text( + json.dumps( + { + "mcpServers": { + "package-docs": { + "url": "https://package.example.com/mcp", + "transport": "http", + }, + }, + }, + ), + encoding="utf-8", + ) + + os.environ["AGENTTEAMS_WORKER_GATEWAY_KEY"] = "test-gateway-key" + config = WorkerConfig( + worker_name="worker-a", + fs_endpoint="unused", + fs_access_key="unused", + fs_secret_key="unused", + install_dir=root / "agents", + ) + RuntimeUpdater(config=config).apply_once( + runtime_config=MemberRuntimeConfig( + path=config.runtime_config_path, + raw={ + "metadata": {"generation": "1"}, + "member": {"runtime": "qwenpaw"}, + "credentials": { + "gatewayKeyEnv": "AGENTTEAMS_WORKER_GATEWAY_KEY", + }, + "desired": { + "agentPackage": { + "ref": package.as_uri(), + "name": "contract-package", + "version": "1.0.0", + "digest": "sha256:contract", + }, + "mcpServers": { + "runtime-docs": { + "url": "https://gateway.example.com/mcp/docs", + "transport": "http", + }, + }, + }, + }, + ), + reapply_adapter=False, + ) + + workspace = config.default_workspace_dir + mcporter = json.loads( + (workspace / "config" / "mcporter.json").read_text(encoding="utf-8"), + ) + assert mcporter["mcpServers"]["runtime-docs"] == { + "url": "https://gateway.example.com/mcp/docs", + "transport": "http", + "headers": {"Authorization": "Bearer test-gateway-key"}, + } + + package_client = load_agent_config("default").mcp.clients["package-docs"] + assert package_client.url == "https://package.example.com/mcp" + assert package_client.transport == "streamable_http" + assert resolve_effective_skills(workspace, "matrix") == ["hot-skill"] + """, + ) + + +def test_qwenpaw2_configures_matrix_overlay_and_sends_text( + tmp_path: Path, +) -> None: + _run_qwenpaw2( + tmp_path, + """ + import asyncio + import importlib.util + import os + from pathlib import Path + import sys + import types + + from qwenpaw.config.config import load_agent_config + from qwenpaw_worker.config import WorkerConfig + from qwenpaw_worker.update import MemberRuntimeConfig, RuntimeUpdater + + root = Path(sys.argv[1]) + os.environ["AGENTTEAMS_MATRIX_URL"] = "http://matrix.example.com:6167" + os.environ["AGENTTEAMS_WORKER_MATRIX_TOKEN"] = "test-matrix-token" + os.environ["AGENTTEAMS_MATRIX_E2EE"] = "false" + config = WorkerConfig( + worker_name="worker-a", + fs_endpoint="unused", + fs_access_key="unused", + fs_secret_key="unused", + install_dir=root / "agents", + ) + + class NoopPackageManager: + def apply(self, _config): + return None + + RuntimeUpdater( + config=config, + package_manager=NoopPackageManager(), + ).apply_once( + runtime_config=MemberRuntimeConfig( + path=config.runtime_config_path, + raw={ + "metadata": {"generation": "1"}, + "team": {"teamRoomId": "!team:matrix.local"}, + "member": { + "runtime": "qwenpaw", + "matrixUserId": "@worker-a:matrix.local", + }, + "credentials": { + "matrixTokenEnv": "AGENTTEAMS_WORKER_MATRIX_TOKEN", + }, + }, + ), + reapply_adapter=False, + ) + + matrix_config = load_agent_config("default").channels.matrix + assert matrix_config.enabled is True + assert matrix_config.homeserver == "http://matrix.example.com:6167" + assert matrix_config.user_id == "@worker-a:matrix.local" + assert matrix_config.access_token == "test-matrix-token" + assert matrix_config.password == "" + assert matrix_config.encryption is False + assert matrix_config.group_disabled is False + assert matrix_config.dm_disabled is False + assert matrix_config.groups["!team:matrix.local"]["requireMention"] is True + + overlay_path = Path( + os.environ["PYTHONPATH"], + ) / "matrix" / "channel.py" + module_name = "qwenpaw.app.channels.matrix.agentteams_contract_channel" + spec = importlib.util.spec_from_file_location(module_name, overlay_path) + assert spec is not None and spec.loader is not None + overlay = importlib.util.module_from_spec(spec) + sys.modules[module_name] = overlay + spec.loader.exec_module(overlay) + + channel = overlay.MatrixChannel.from_config( + lambda *_args, **_kwargs: None, + matrix_config, + workspace_dir=config.default_workspace_dir, + ) + assert channel.homeserver == "http://matrix.example.com:6167" + assert channel.matrix_user_id == "@worker-a:matrix.local" + assert channel.get_debounce_key( + {"meta": {"room_id": "!team:matrix.local"}}, + ) == "matrix:!team:matrix.local" + + calls = [] + + class FakeClient: + async def room_send(self, *args, **kwargs): + calls.append((args, kwargs)) + return types.SimpleNamespace(event_id="$sent") + + async def room_typing(self, *_args, **_kwargs): + return None + + channel._client = FakeClient() + asyncio.run( + channel.send( + "!team:matrix.local", + "hello from QwenPaw 2", + {"room_id": "!team:matrix.local"}, + ), + ) + assert len(calls) == 1 + args, kwargs = calls[0] + assert args[0] == "!team:matrix.local" + assert args[1] == "m.room.message" + assert args[2]["msgtype"] == "m.text" + assert args[2]["body"] == "hello from QwenPaw 2" + assert kwargs["tx_id"] + """, + ) diff --git a/qwenpaw/tests/test_qwenpaw2_startup_contract.py b/qwenpaw/tests/test_qwenpaw2_startup_contract.py new file mode 100644 index 000000000..7b6ad0336 --- /dev/null +++ b/qwenpaw/tests/test_qwenpaw2_startup_contract.py @@ -0,0 +1,9 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def test_qwenpaw_2_uses_native_mcp_startup_without_source_patch() -> None: + dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") + + assert "patch-qwenpaw-defer-mcp-startup.py" not in dockerfile diff --git a/qwenpaw/tests/test_runtime_dependencies.py b/qwenpaw/tests/test_runtime_dependencies.py new file mode 100644 index 000000000..b0b873257 --- /dev/null +++ b/qwenpaw/tests/test_runtime_dependencies.py @@ -0,0 +1,20 @@ +from pathlib import Path +import tomllib + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_qwenpaw_acp_api_is_compatible() -> None: + from acp import SetSessionModelResponse + + assert SetSessionModelResponse is not None + + +def test_worker_and_image_target_qwenpaw_2_post3() -> None: + project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") + + assert project["project"]["requires-python"] == ">=3.11,<3.14" + assert "qwenpaw==2.0.0.post3" in project["project"]["dependencies"] + assert "ARG QWENPAW_PIP_SPEC=qwenpaw==2.0.0.post3" in dockerfile diff --git a/qwenpaw/tests/test_worker_lifecycle.py b/qwenpaw/tests/test_worker_lifecycle.py index 7cf03f5bd..6a44ec23a 100644 --- a/qwenpaw/tests/test_worker_lifecycle.py +++ b/qwenpaw/tests/test_worker_lifecycle.py @@ -150,6 +150,19 @@ def test_link_workspace_shared_points_to_canonical_shared(tmp_path: Path) -> Non assert (config.shared_dir / "tasks" / "task-1" / "workspace" / "note.txt").read_text(encoding="utf-8") == "ready\n" +def test_builtin_qwenpaw_plugins_default_matches_image_layout( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("AGENTTEAMS_BUILTIN_QWENPAW_PLUGINS_DIR", raising=False) + + worker = Worker(_config(tmp_path)) + + assert worker._builtin_qwenpaw_plugins_dir() == Path( + "/opt/agentteams/qwenpaw-builtin/plugins", + ) + + def test_configure_qwenpaw_runtime_uses_workspace_teams_prompt( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, From a38333b7694e9ce456223e06b46b867ac76764df Mon Sep 17 00:00:00 2001 From: "zunfei.lzf" Date: Sat, 25 Jul 2026 23:48:15 +0800 Subject: [PATCH 02/37] fix(qwenpaw): wire worker image into local installs Change-Id: I7d7930c2391217d2c1887eb4d7e20449b4b16ce8 --- Makefile | 3 ++- agentteams-controller/internal/config/config.go | 3 +++ .../internal/config/config_test.go | 16 ++++++++++++++++ changelog/current.md | 2 +- install/agentteams-install.ps1 | 13 ++++++++++++- install/agentteams-install.sh | 8 +++++++- 6 files changed, 41 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 87073e84d..5662c0614 100644 --- a/Makefile +++ b/Makefile @@ -673,7 +673,7 @@ uninstall: ## Stop and remove Manager + all Worker containers install-embedded: ## Install in embedded mode (dual-container: controller + agent) ifndef SKIP_BUILD - $(MAKE) build-embedded build-manager build-manager-copaw build-worker build-copaw-worker build-hermes-worker + $(MAKE) build-embedded build-manager build-manager-copaw build-worker build-copaw-worker build-qwenpaw-worker build-hermes-worker endif @echo "==> Installing AgentTeams (embedded mode)..." AGENTTEAMS_NON_INTERACTIVE=1 \ @@ -682,6 +682,7 @@ endif AGENTTEAMS_INSTALL_MANAGER_COPAW_IMAGE=$(LOCAL_MANAGER_COPAW) \ AGENTTEAMS_INSTALL_WORKER_IMAGE=$(LOCAL_WORKER) \ AGENTTEAMS_INSTALL_COPAW_WORKER_IMAGE=$(LOCAL_COPAW_WORKER) \ + AGENTTEAMS_INSTALL_QWENPAW_WORKER_IMAGE=$(LOCAL_QWENPAW_WORKER) \ AGENTTEAMS_INSTALL_HERMES_WORKER_IMAGE=$(LOCAL_HERMES_WORKER) \ AGENTTEAMS_INSTALL_OPENHUMAN_WORKER_IMAGE=$(LOCAL_OPENHUMAN_WORKER) \ AGENTTEAMS_MATRIX_E2EE=0 \ diff --git a/agentteams-controller/internal/config/config.go b/agentteams-controller/internal/config/config.go index 7e4389eda..f8d1537ea 100644 --- a/agentteams-controller/internal/config/config.go +++ b/agentteams-controller/internal/config/config.go @@ -513,6 +513,7 @@ func (c *Config) DockerConfig() backend.DockerConfig { CopawWorkerImage: envOrDefault("AGENTTEAMS_COPAW_WORKER_IMAGE", "agentteams/agentteams-copaw-worker:latest"), HermesWorkerImage: envOrDefault("AGENTTEAMS_HERMES_WORKER_IMAGE", "agentteams/agentteams-hermes-worker:latest"), OpenHumanWorkerImage: envOrDefault("AGENTTEAMS_OPENHUMAN_WORKER_IMAGE", "agentteams/agentteams-openhuman-worker:latest"), + QwenPawWorkerImage: envOrDefault("AGENTTEAMS_QWENPAW_WORKER_IMAGE", "agentteams/agentteams-qwenpaw-worker:latest"), DefaultNetwork: envOrDefault("AGENTTEAMS_DOCKER_NETWORK", "agentteams-net"), } } @@ -555,6 +556,7 @@ func (c *Config) K8sConfig() backend.K8sConfig { CopawWorkerImage: envOrDefault("AGENTTEAMS_COPAW_WORKER_IMAGE", "agentteams/agentteams-copaw-worker:latest"), HermesWorkerImage: envOrDefault("AGENTTEAMS_HERMES_WORKER_IMAGE", "agentteams/agentteams-hermes-worker:latest"), OpenHumanWorkerImage: envOrDefault("AGENTTEAMS_OPENHUMAN_WORKER_IMAGE", "agentteams/agentteams-openhuman-worker:latest"), + QwenPawWorkerImage: envOrDefault("AGENTTEAMS_QWENPAW_WORKER_IMAGE", "agentteams/agentteams-qwenpaw-worker:latest"), WorkerCPU: c.K8sWorkerCPU, WorkerMemory: c.K8sWorkerMemory, ControllerName: c.ControllerName, @@ -571,6 +573,7 @@ func (c *Config) SandboxConfig() backend.SandboxConfig { CopawWorkerImage: envOrDefault("AGENTTEAMS_COPAW_WORKER_IMAGE", "agentteams/agentteams-copaw-worker:latest"), HermesWorkerImage: envOrDefault("AGENTTEAMS_HERMES_WORKER_IMAGE", "agentteams/agentteams-hermes-worker:latest"), OpenHumanWorkerImage: envOrDefault("AGENTTEAMS_OPENHUMAN_WORKER_IMAGE", "agentteams/agentteams-openhuman-worker:latest"), + QwenPawWorkerImage: envOrDefault("AGENTTEAMS_QWENPAW_WORKER_IMAGE", "agentteams/agentteams-qwenpaw-worker:latest"), WorkerCPU: c.K8sWorkerCPU, WorkerMemory: c.K8sWorkerMemory, SandboxPrewarmSize: c.SandboxPrewarmSize, diff --git a/agentteams-controller/internal/config/config_test.go b/agentteams-controller/internal/config/config_test.go index a43ff6097..8eef000e6 100644 --- a/agentteams-controller/internal/config/config_test.go +++ b/agentteams-controller/internal/config/config_test.go @@ -130,6 +130,22 @@ func TestLoadConfigUsesManagerEnvFallback(t *testing.T) { } } +func TestBackendConfigsIncludeQwenPawWorkerImage(t *testing.T) { + t.Setenv("AGENTTEAMS_QWENPAW_WORKER_IMAGE", "agentteams/qwenpaw-worker:test") + + cfg := LoadConfig() + + for name, got := range map[string]string{ + "docker": cfg.DockerConfig().QwenPawWorkerImage, + "k8s": cfg.K8sConfig().QwenPawWorkerImage, + "sandbox": cfg.SandboxConfig().QwenPawWorkerImage, + } { + if want := "agentteams/qwenpaw-worker:test"; got != want { + t.Fatalf("%s QwenPawWorkerImage = %q, want %q", name, got, want) + } + } +} + func TestLoadConfigPanicsOnInvalidManagerSpec(t *testing.T) { t.Setenv("AGENTTEAMS_MANAGER_SPEC", "{") diff --git a/changelog/current.md b/changelog/current.md index f457d29c8..f1afc684e 100644 --- a/changelog/current.md +++ b/changelog/current.md @@ -6,7 +6,7 @@ Record image-affecting changes to `manager/`, `worker/`, `copaw/`, `hermes/`, `o **Bug Fixes** -- **QwenPaw local startup readiness**: Wait for the runtime-owned `runtime/runtime.yaml` object before creating a local Docker QwenPaw Worker. +- **QwenPaw local startup readiness**: Select and propagate the QwenPaw Worker image across install backends, then wait for the runtime-owned `runtime/runtime.yaml` object before creating a local Docker Worker. - **Team Worker room boundary convergence**: Remove Manager again after standalone Worker infrastructure reconciliation restores regular Team Worker personal-room membership. ([b5b0add](https://github.com/agentscope-ai/AgentTeams/commit/b5b0add)) - **Team Worker reference enforcement**: Keep referenced Worker CRs protected during direct deletion and reject Team API members whose required role is empty. ([d96f1ed](https://github.com/agentscope-ai/AgentTeams/commit/d96f1ed)) - **Team Worker room membership**: Force Manager out of regular Team Worker personal rooms when equal Matrix power levels prevent a normal kick. ([43545c2](https://github.com/agentscope-ai/AgentTeams/commit/43545c2)) diff --git a/install/agentteams-install.ps1 b/install/agentteams-install.ps1 index 09cbdf656..5602dd791 100644 --- a/install/agentteams-install.ps1 +++ b/install/agentteams-install.ps1 @@ -29,6 +29,7 @@ # AGENTTEAMS_INSTALL_MANAGER_IMAGE Override manager image (e.g., local build) # AGENTTEAMS_INSTALL_WORKER_IMAGE Override worker image (e.g., local build) # AGENTTEAMS_INSTALL_COPAW_WORKER_IMAGE Override copaw worker image (e.g., local build) +# AGENTTEAMS_INSTALL_QWENPAW_WORKER_IMAGE Override qwenpaw worker image (e.g., local build) # AGENTTEAMS_INSTALL_HERMES_WORKER_IMAGE Override hermes worker image (e.g., local build) # AGENTTEAMS_PORT_GATEWAY Host port for Higress gateway (default: 18080) # AGENTTEAMS_PORT_CONSOLE Host port for Higress console (default: 18001) @@ -1059,6 +1060,7 @@ AGENTTEAMS_CMS_METRICS_ENABLED=$(if ($env:AGENTTEAMS_CMS_METRICS_ENABLED) { $env # Worker images (for direct container creation) AGENTTEAMS_WORKER_IMAGE=$($Config.WORKER_IMAGE) AGENTTEAMS_COPAW_WORKER_IMAGE=$($Config.COPAW_WORKER_IMAGE) +AGENTTEAMS_QWENPAW_WORKER_IMAGE=$($Config.QWENPAW_WORKER_IMAGE) AGENTTEAMS_HERMES_WORKER_IMAGE=$($Config.HERMES_WORKER_IMAGE) # Manager runtime (openclaw | copaw) @@ -2480,6 +2482,12 @@ function Install-Manager { "$($script:AGENTTEAMS_REGISTRY)/agentteams/agentteams-copaw-worker:$($script:AGENTTEAMS_VERSION)" } + $script:QWENPAW_WORKER_IMAGE = if ($env:AGENTTEAMS_INSTALL_QWENPAW_WORKER_IMAGE) { + $env:AGENTTEAMS_INSTALL_QWENPAW_WORKER_IMAGE + } else { + "$($script:AGENTTEAMS_REGISTRY)/agentteams/agentteams-qwenpaw-worker:$($script:AGENTTEAMS_VERSION)" + } + $script:HERMES_WORKER_IMAGE = if ($env:AGENTTEAMS_INSTALL_HERMES_WORKER_IMAGE) { $env:AGENTTEAMS_INSTALL_HERMES_WORKER_IMAGE } else { @@ -2666,6 +2674,7 @@ function Install-Manager { $config.REGISTRY = $script:AGENTTEAMS_REGISTRY $config.WORKER_IMAGE = $script:WORKER_IMAGE $config.COPAW_WORKER_IMAGE = $script:COPAW_WORKER_IMAGE + $config.QWENPAW_WORKER_IMAGE = $script:QWENPAW_WORKER_IMAGE $config.HERMES_WORKER_IMAGE = $script:HERMES_WORKER_IMAGE $config.MANAGER_COPAW_IMAGE = $script:MANAGER_COPAW_IMAGE @@ -2742,6 +2751,7 @@ function Install-Manager { --security-opt label=disable ` -e "AGENTTEAMS_WORKER_IMAGE=$($script:WORKER_IMAGE)" ` -e "AGENTTEAMS_COPAW_WORKER_IMAGE=$($script:COPAW_WORKER_IMAGE)" ` + -e "AGENTTEAMS_QWENPAW_WORKER_IMAGE=$($script:QWENPAW_WORKER_IMAGE)" ` -e "AGENTTEAMS_HERMES_WORKER_IMAGE=$($script:HERMES_WORKER_IMAGE)" ` -e "AGENTTEAMS_DEFAULT_WORKER_RUNTIME=$($script:config.DEFAULT_WORKER_RUNTIME)" ` $(if ($config.PROXY_ALLOWED_REGISTRIES) { @("-e", "AGENTTEAMS_PROXY_ALLOWED_REGISTRIES=$($config.PROXY_ALLOWED_REGISTRIES)") }) ` @@ -2839,7 +2849,7 @@ function Install-Manager { } # Pull all worker runtime images (workers may use any runtime regardless of the default) - foreach ($workerImg in @($script:WORKER_IMAGE, $script:COPAW_WORKER_IMAGE, $script:HERMES_WORKER_IMAGE)) { + foreach ($workerImg in @($script:WORKER_IMAGE, $script:COPAW_WORKER_IMAGE, $script:QWENPAW_WORKER_IMAGE, $script:HERMES_WORKER_IMAGE)) { if ($workerImg -match $LocalImagePattern) { if (Test-LocalImage $workerImg) { Write-Log (Get-Msg "install.image.worker_exists" -f $workerImg) @@ -3036,6 +3046,7 @@ function Install-Manager { "-e", "AGENTTEAMS_DEFAULT_WORKER_RUNTIME=$($config.DEFAULT_WORKER_RUNTIME)", "-e", "AGENTTEAMS_WORKER_IMAGE=$($script:WORKER_IMAGE)", "-e", "AGENTTEAMS_COPAW_WORKER_IMAGE=$($script:COPAW_WORKER_IMAGE)", + "-e", "AGENTTEAMS_QWENPAW_WORKER_IMAGE=$($script:QWENPAW_WORKER_IMAGE)", "-e", "AGENTTEAMS_HERMES_WORKER_IMAGE=$($script:HERMES_WORKER_IMAGE)", "-e", "AGENTTEAMS_MATRIX_DOMAIN=$matrixDomain", "-e", "AGENTTEAMS_ELEMENT_HOMESERVER_URL=http://127.0.0.1:$($config.PORT_GATEWAY)", diff --git a/install/agentteams-install.sh b/install/agentteams-install.sh index 9872b7237..a201e542f 100755 --- a/install/agentteams-install.sh +++ b/install/agentteams-install.sh @@ -1020,6 +1020,7 @@ MANAGER_IMAGE="${AGENTTEAMS_INSTALL_MANAGER_IMAGE:-}" MANAGER_COPAW_IMAGE="${AGENTTEAMS_INSTALL_MANAGER_COPAW_IMAGE:-}" WORKER_IMAGE="${AGENTTEAMS_INSTALL_WORKER_IMAGE:-}" COPAW_WORKER_IMAGE="${AGENTTEAMS_INSTALL_COPAW_WORKER_IMAGE:-}" +QWENPAW_WORKER_IMAGE="${AGENTTEAMS_INSTALL_QWENPAW_WORKER_IMAGE:-}" HERMES_WORKER_IMAGE="${AGENTTEAMS_INSTALL_HERMES_WORKER_IMAGE:-}" CONTROLLER_IMAGE="${AGENTTEAMS_INSTALL_CONTROLLER_IMAGE:-}" @@ -1028,6 +1029,7 @@ resolve_image_tags() { MANAGER_COPAW_IMAGE="${AGENTTEAMS_INSTALL_MANAGER_COPAW_IMAGE:-${AGENTTEAMS_REGISTRY}/agentteams/agentteams-manager-copaw:${AGENTTEAMS_VERSION}}" WORKER_IMAGE="${AGENTTEAMS_INSTALL_WORKER_IMAGE:-${AGENTTEAMS_REGISTRY}/agentteams/agentteams-worker:${AGENTTEAMS_VERSION}}" COPAW_WORKER_IMAGE="${AGENTTEAMS_INSTALL_COPAW_WORKER_IMAGE:-${AGENTTEAMS_REGISTRY}/agentteams/agentteams-copaw-worker:${AGENTTEAMS_VERSION}}" + QWENPAW_WORKER_IMAGE="${AGENTTEAMS_INSTALL_QWENPAW_WORKER_IMAGE:-${AGENTTEAMS_REGISTRY}/agentteams/agentteams-qwenpaw-worker:${AGENTTEAMS_VERSION}}" HERMES_WORKER_IMAGE="${AGENTTEAMS_INSTALL_HERMES_WORKER_IMAGE:-${AGENTTEAMS_REGISTRY}/agentteams/agentteams-hermes-worker:${AGENTTEAMS_VERSION}}" EMBEDDED_IMAGE="${AGENTTEAMS_INSTALL_EMBEDDED_IMAGE:-${AGENTTEAMS_REGISTRY}/agentteams/agentteams-embedded:${AGENTTEAMS_VERSION}}" # CoPaw Worker introduced in v1.0.4; Hermes Worker introduced in v1.1.0 @@ -3068,9 +3070,10 @@ AGENTTEAMS_CMS_METRICS_ENABLED=${AGENTTEAMS_CMS_METRICS_ENABLED:-false} # Worker images (for direct container creation) AGENTTEAMS_WORKER_IMAGE=${WORKER_IMAGE} AGENTTEAMS_COPAW_WORKER_IMAGE=${COPAW_WORKER_IMAGE} +AGENTTEAMS_QWENPAW_WORKER_IMAGE=${QWENPAW_WORKER_IMAGE} AGENTTEAMS_HERMES_WORKER_IMAGE=${HERMES_WORKER_IMAGE} -# Default Worker runtime (openclaw | copaw | hermes) +# Default Worker runtime (openclaw | copaw | qwenpaw | hermes) AGENTTEAMS_DEFAULT_WORKER_RUNTIME=${AGENTTEAMS_DEFAULT_WORKER_RUNTIME:-copaw} # Matrix E2EE (0=disabled, 1=enabled; default: 0) @@ -3240,6 +3243,7 @@ EOF # Pull all worker runtime images (workers may use any runtime regardless of the default) _pull_image "${WORKER_IMAGE}" "install.image.worker_exists" "install.image.pulling_worker" _pull_image "${COPAW_WORKER_IMAGE}" "install.image.worker_exists" "install.image.pulling_worker" + _pull_image "${QWENPAW_WORKER_IMAGE}" "install.image.worker_exists" "install.image.pulling_worker" _pull_image "${HERMES_WORKER_IMAGE}" "install.image.worker_exists" "install.image.pulling_worker" # --- Pre-upgrade: extract Matrix passwords from running old containers --- @@ -3424,6 +3428,7 @@ CREDEOF -e "AGENTTEAMS_DEFAULT_WORKER_RUNTIME=${AGENTTEAMS_DEFAULT_WORKER_RUNTIME:-copaw}" -e "AGENTTEAMS_WORKER_IMAGE=${WORKER_IMAGE}" -e "AGENTTEAMS_COPAW_WORKER_IMAGE=${COPAW_WORKER_IMAGE}" + -e "AGENTTEAMS_QWENPAW_WORKER_IMAGE=${QWENPAW_WORKER_IMAGE}" -e "AGENTTEAMS_HERMES_WORKER_IMAGE=${HERMES_WORKER_IMAGE}" -e "AGENTTEAMS_MATRIX_DOMAIN=${_matrix_domain}" -e "AGENTTEAMS_ELEMENT_HOMESERVER_URL=http://127.0.0.1:${AGENTTEAMS_PORT_GATEWAY}" @@ -3662,6 +3667,7 @@ CREDEOF --security-opt label=disable \ -e AGENTTEAMS_WORKER_IMAGE="${WORKER_IMAGE}" \ -e AGENTTEAMS_COPAW_WORKER_IMAGE="${COPAW_WORKER_IMAGE}" \ + -e AGENTTEAMS_QWENPAW_WORKER_IMAGE="${QWENPAW_WORKER_IMAGE}" \ -e AGENTTEAMS_HERMES_WORKER_IMAGE="${HERMES_WORKER_IMAGE}" \ ${AGENTTEAMS_PROXY_ALLOWED_REGISTRIES:+-e AGENTTEAMS_PROXY_ALLOWED_REGISTRIES="${AGENTTEAMS_PROXY_ALLOWED_REGISTRIES}"} \ --restart unless-stopped \ From 756a5a0241cfd23c5d0dc10ea57d631c2e21e448 Mon Sep 17 00:00:00 2001 From: "zunfei.lzf" Date: Sun, 26 Jul 2026 01:09:12 +0800 Subject: [PATCH 03/37] fix(qwenpaw): apply inline worker prompts Change-Id: I3073b27f43f0ecde4e6fa2164fd49d2659d50dcf --- .../internal/service/deployer_test.go | 17 +++++++- .../internal/service/runtime_config.go | 14 +++++++ changelog/current.md | 1 + qwenpaw/src/qwenpaw_worker/update.py | 23 ++++++++++- qwenpaw/src/qwenpaw_worker/worker.py | 2 +- qwenpaw/tests/test_package_update.py | 41 ++++++++++++++++++- qwenpaw/tests/test_worker_lifecycle.py | 2 +- 7 files changed, 94 insertions(+), 6 deletions(-) diff --git a/agentteams-controller/internal/service/deployer_test.go b/agentteams-controller/internal/service/deployer_test.go index ee8f49bb3..9eb81e23a 100644 --- a/agentteams-controller/internal/service/deployer_test.go +++ b/agentteams-controller/internal/service/deployer_test.go @@ -346,8 +346,11 @@ func TestDeployMemberRuntimeConfigWritesAgentScopedYaml(t *testing.T) { MatrixUserID: "@human:matrix.local", }}, Spec: v1beta1.WorkerSpec{ - Model: "qwen-plus", - Package: "nacos://registry/ns/dev-worker?version=1.2.0", + Model: "qwen-plus", + Package: "nacos://registry/ns/dev-worker?version=1.2.0", + Identity: "frontend specialist", + Soul: "build accessible user interfaces", + Agents: "follow the project workflow", AgentIdentity: &v1beta1.AgentIdentitySpec{ WorkloadIdentityName: "wi-worker-a", }, @@ -430,6 +433,16 @@ func TestDeployMemberRuntimeConfigWritesAgentScopedYaml(t *testing.T) { if got := fmt.Sprint(model["gatewayUrl"]); got != "https://aigw.example.com" { t.Fatalf("desired.model.gatewayUrl=%q", got) } + inlineConfig := desired["inlineConfig"].(map[string]any) + if got := fmt.Sprint(inlineConfig["identity"]); got != "frontend specialist" { + t.Fatalf("desired.inlineConfig.identity=%q", got) + } + if got := fmt.Sprint(inlineConfig["soul"]); got != "build accessible user interfaces" { + t.Fatalf("desired.inlineConfig.soul=%q", got) + } + if got := fmt.Sprint(inlineConfig["agents"]); got != "follow the project workflow" { + t.Fatalf("desired.inlineConfig.agents=%q", got) + } agentPackage := desired["agentPackage"].(map[string]any) if got := fmt.Sprint(agentPackage["ref"]); got != "nacos://registry/ns/dev-worker?version=1.2.0" { t.Fatalf("desired.agentPackage.ref=%q", got) diff --git a/agentteams-controller/internal/service/runtime_config.go b/agentteams-controller/internal/service/runtime_config.go index 3c1538591..eecc45f4a 100644 --- a/agentteams-controller/internal/service/runtime_config.go +++ b/agentteams-controller/internal/service/runtime_config.go @@ -69,6 +69,7 @@ type memberRuntimeConfigMatrix struct { type memberRuntimeConfigDesired struct { Model *memberRuntimeConfigModel `json:"model,omitempty"` AgentPackage *memberRuntimeConfigAgentPackage `json:"agentPackage,omitempty"` + InlineConfig *memberRuntimeConfigInlineConfig `json:"inlineConfig,omitempty"` SkillRegistry *memberRuntimeConfigSkillRegistry `json:"skillRegistry,omitempty"` MCPServers []v1beta1.MCPServer `json:"mcpServers,omitempty"` ChannelPolicy *v1beta1.ChannelPolicySpec `json:"channelPolicy,omitempty"` @@ -89,6 +90,12 @@ type memberRuntimeConfigAgentPackage struct { Ref string `json:"ref"` } +type memberRuntimeConfigInlineConfig struct { + Identity string `json:"identity,omitempty"` + Soul string `json:"soul,omitempty"` + Agents string `json:"agents,omitempty"` +} + type memberRuntimeConfigSkillRegistry struct { Provider string `json:"provider,omitempty"` URL string `json:"url,omitempty"` @@ -265,6 +272,13 @@ func (d *Deployer) memberRuntimeConfigDocument(req MemberRuntimeConfigDeployRequ if req.Spec.Package != "" { desired.AgentPackage = &memberRuntimeConfigAgentPackage{Ref: req.Spec.Package} } + if req.Spec.Identity != "" || req.Spec.Soul != "" || req.Spec.Agents != "" { + desired.InlineConfig = &memberRuntimeConfigInlineConfig{ + Identity: req.Spec.Identity, + Soul: req.Spec.Soul, + Agents: req.Spec.Agents, + } + } if req.SkillRegistryURL != "" || req.SkillRegistryAuthType != "" { desired.SkillRegistry = &memberRuntimeConfigSkillRegistry{ Provider: "nacos", diff --git a/changelog/current.md b/changelog/current.md index f1afc684e..2d3f44ddb 100644 --- a/changelog/current.md +++ b/changelog/current.md @@ -6,6 +6,7 @@ Record image-affecting changes to `manager/`, `worker/`, `copaw/`, `hermes/`, `o **Bug Fixes** +- **QwenPaw inline prompt compatibility**: Project Worker identity, SOUL, and AGENTS content through runtime desired state and apply it to the native QwenPaw workspace. - **QwenPaw local startup readiness**: Select and propagate the QwenPaw Worker image across install backends, then wait for the runtime-owned `runtime/runtime.yaml` object before creating a local Docker Worker. - **Team Worker room boundary convergence**: Remove Manager again after standalone Worker infrastructure reconciliation restores regular Team Worker personal-room membership. ([b5b0add](https://github.com/agentscope-ai/AgentTeams/commit/b5b0add)) - **Team Worker reference enforcement**: Keep referenced Worker CRs protected during direct deletion and reject Team API members whose required role is empty. ([d96f1ed](https://github.com/agentscope-ai/AgentTeams/commit/d96f1ed)) diff --git a/qwenpaw/src/qwenpaw_worker/update.py b/qwenpaw/src/qwenpaw_worker/update.py index 4f2d0d8fb..8a20cb7f9 100644 --- a/qwenpaw/src/qwenpaw_worker/update.py +++ b/qwenpaw/src/qwenpaw_worker/update.py @@ -349,6 +349,10 @@ def agent_package_identity(self) -> Tuple[str, str, str, str]: _string(package.get("digest")), ) + @property + def inline_config(self) -> Dict[str, str]: + return _string_fields(_section(self.desired, "inlineConfig"), ("identity", "soul", "agents")) + @property def model(self) -> Dict[str, Any]: return _section(self.desired, "model") @@ -372,9 +376,10 @@ def channel_policy(self) -> Dict[str, Any]: return _section(self.desired, "channelPolicy") @property - def desired_identity(self) -> Tuple[str, str, str, str, str, str, str, str]: + def desired_identity(self) -> Tuple[str, str, str, str, str, str, str, str, str]: return ( *self.agent_package_identity, + _stable_json(self.inline_config), _stable_json(self.model), _stable_json(self.mcp_servers), _stable_json(self.channels), @@ -1609,6 +1614,7 @@ def apply_once( self._apply_team_context_prompt(config) applied_package = self.package_manager.apply(config) + self._apply_inline_config(config) adapter_applied = False if adapter_should_apply: @@ -1633,6 +1639,21 @@ def apply_once( ) return ApplyResult(runtime_config=config, changed=True, agent_package_dir=applied_package) + def _apply_inline_config(self, config: MemberRuntimeConfig) -> None: + prompt_files = { + "IDENTITY.md": config.inline_config.get("identity", ""), + "SOUL.md": config.inline_config.get("soul", ""), + "AGENTS.md": config.inline_config.get("agents", ""), + } + for file_name, content in prompt_files.items(): + if not content: + continue + path = self.config.default_workspace_dir / file_name + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(f".{path.name}.tmp") + tmp.write_text(f"{content.rstrip()}\n", encoding="utf-8") + tmp.replace(path) + def _sync_model_runtime_if_needed( self, previous: Optional[MemberRuntimeConfig], diff --git a/qwenpaw/src/qwenpaw_worker/worker.py b/qwenpaw/src/qwenpaw_worker/worker.py index 91a800509..eac36313a 100644 --- a/qwenpaw/src/qwenpaw_worker/worker.py +++ b/qwenpaw/src/qwenpaw_worker/worker.py @@ -436,7 +436,7 @@ def _configure_qwenpaw_runtime(self) -> None: agent_config.workspace_dir = str(self.config.default_workspace_dir) agent_config.approval_level = "AUTO" prompt_files = list(agent_config.system_prompt_files or []) - for file_name in ("AGENTS.md", "SOUL.md", "TEAMS.md"): + for file_name in ("AGENTS.md", "SOUL.md", "TEAMS.md", "IDENTITY.md"): if file_name not in prompt_files: prompt_files.append(file_name) agent_config.system_prompt_files = prompt_files diff --git a/qwenpaw/tests/test_package_update.py b/qwenpaw/tests/test_package_update.py index 953469926..53fbbcf6c 100644 --- a/qwenpaw/tests/test_package_update.py +++ b/qwenpaw/tests/test_package_update.py @@ -11,7 +11,7 @@ import types from urllib.parse import parse_qs, urlparse -from qwenpaw_worker.update import AgentPackageManager, MemberRuntimeConfig, _strip_json_line_comments +from qwenpaw_worker.update import AgentPackageManager, MemberRuntimeConfig, RuntimeUpdater, _strip_json_line_comments def _package( @@ -135,6 +135,45 @@ def _runtime_config_without_package(tmp_path: Path) -> MemberRuntimeConfig: return MemberRuntimeConfig.load(path) +def _runtime_config_with_inline_config(tmp_path: Path) -> MemberRuntimeConfig: + path = tmp_path / "runtime-inline.yaml" + path.write_text( + """ +kind: MemberRuntimeConfig +metadata: + generation: inline +member: + name: worker-a + runtime: qwenpaw +desired: + inlineConfig: + identity: Frontend specialist + soul: Build accessible user interfaces + agents: Follow the project workflow +""", + encoding="utf-8", + ) + return MemberRuntimeConfig.load(path) + + +def test_runtime_updater_applies_inline_prompt_config_to_workspace(tmp_path: Path) -> None: + workspace_dir = tmp_path / "workspace" + config = types.SimpleNamespace( + worker_name="worker-a", + qwenpaw_working_dir=tmp_path / "qwenpaw", + default_workspace_dir=workspace_dir, + runtime_config_path=tmp_path / "runtime.yaml", + agent_role="worker", + ) + updater = RuntimeUpdater(config, model_runtime_sync=lambda _config: None) + + updater.apply_once(runtime_config=_runtime_config_with_inline_config(tmp_path)) + + assert (workspace_dir / "IDENTITY.md").read_text(encoding="utf-8") == "Frontend specialist\n" + assert (workspace_dir / "SOUL.md").read_text(encoding="utf-8") == "Build accessible user interfaces\n" + assert (workspace_dir / "AGENTS.md").read_text(encoding="utf-8") == "Follow the project workflow\n" + + def _install_fake_qwenpaw_mcp_config(monkeypatch): saved: dict[str, object] = {} agent_config = types.SimpleNamespace(mcp=None) diff --git a/qwenpaw/tests/test_worker_lifecycle.py b/qwenpaw/tests/test_worker_lifecycle.py index 6a44ec23a..3576c2452 100644 --- a/qwenpaw/tests/test_worker_lifecycle.py +++ b/qwenpaw/tests/test_worker_lifecycle.py @@ -223,7 +223,7 @@ def __init__(self, id, name, description, workspace_dir): workspace = tmp_path / "agents" / "worker-a" / ".qwenpaw" / "workspaces" / "default" assert saved.workspace_dir == str(workspace) assert saved.approval_level == "AUTO" - assert saved.system_prompt_files == ["AGENTS.md", "SOUL.md", "TEAMS.md"] + assert saved.system_prompt_files == ["AGENTS.md", "SOUL.md", "TEAMS.md", "IDENTITY.md"] assert not any("shared" in prompt for prompt in saved.system_prompt_files) assert saved.running.shell_command_executable == "custom-shell" From 88f97c2a66229483b5c0faf958cef6af21d74502 Mon Sep 17 00:00:00 2001 From: "zunfei.lzf" Date: Sun, 26 Jul 2026 09:59:17 +0800 Subject: [PATCH 04/37] fix(qwenpaw): preserve worker prompts and CI diagnostics Change-Id: I31687da14f2d40bfb50dade36a8d7467e9aa8ddd --- changelog/current.md | 3 +- qwenpaw/src/qwenpaw_worker/update.py | 11 ++- qwenpaw/tests/test_package_update.py | 5 + scripts/export-debug-log.py | 99 ++++++++++++++++++- scripts/tests/test_export_debug_log.py | 51 ++++++++++ ...test-26-qwenpaw-teamharness-plugin-mode.sh | 16 ++- 6 files changed, 176 insertions(+), 9 deletions(-) diff --git a/changelog/current.md b/changelog/current.md index 2d3f44ddb..b26bddb4f 100644 --- a/changelog/current.md +++ b/changelog/current.md @@ -6,7 +6,8 @@ Record image-affecting changes to `manager/`, `worker/`, `copaw/`, `hermes/`, `o **Bug Fixes** -- **QwenPaw inline prompt compatibility**: Project Worker identity, SOUL, and AGENTS content through runtime desired state and apply it to the native QwenPaw workspace. +- **QwenPaw inline prompt compatibility**: Project Worker identity, SOUL, and AGENTS content through runtime desired state and apply it to both the native QwenPaw workspace and the Worker root storage contract. +- **Integration failure diagnostics**: Export AgentTeams container state and timestamped logs with CI artifacts, and stop the QwenPaw TeamHarness shard after startup failures instead of hiding the cause behind cascading timeouts. - **QwenPaw local startup readiness**: Select and propagate the QwenPaw Worker image across install backends, then wait for the runtime-owned `runtime/runtime.yaml` object before creating a local Docker Worker. - **Team Worker room boundary convergence**: Remove Manager again after standalone Worker infrastructure reconciliation restores regular Team Worker personal-room membership. ([b5b0add](https://github.com/agentscope-ai/AgentTeams/commit/b5b0add)) - **Team Worker reference enforcement**: Keep referenced Worker CRs protected during direct deletion and reject Team API members whose required role is empty. ([d96f1ed](https://github.com/agentscope-ai/AgentTeams/commit/d96f1ed)) diff --git a/qwenpaw/src/qwenpaw_worker/update.py b/qwenpaw/src/qwenpaw_worker/update.py index 8a20cb7f9..37ff20be9 100644 --- a/qwenpaw/src/qwenpaw_worker/update.py +++ b/qwenpaw/src/qwenpaw_worker/update.py @@ -1648,11 +1648,12 @@ def _apply_inline_config(self, config: MemberRuntimeConfig) -> None: for file_name, content in prompt_files.items(): if not content: continue - path = self.config.default_workspace_dir / file_name - path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_name(f".{path.name}.tmp") - tmp.write_text(f"{content.rstrip()}\n", encoding="utf-8") - tmp.replace(path) + for base_dir in (self.config.default_workspace_dir, self.config.worker_home): + path = base_dir / file_name + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(f".{path.name}.tmp") + tmp.write_text(f"{content.rstrip()}\n", encoding="utf-8") + tmp.replace(path) def _sync_model_runtime_if_needed( self, diff --git a/qwenpaw/tests/test_package_update.py b/qwenpaw/tests/test_package_update.py index 53fbbcf6c..bbdc58749 100644 --- a/qwenpaw/tests/test_package_update.py +++ b/qwenpaw/tests/test_package_update.py @@ -158,8 +158,10 @@ def _runtime_config_with_inline_config(tmp_path: Path) -> MemberRuntimeConfig: def test_runtime_updater_applies_inline_prompt_config_to_workspace(tmp_path: Path) -> None: workspace_dir = tmp_path / "workspace" + worker_home = tmp_path / "worker-home" config = types.SimpleNamespace( worker_name="worker-a", + worker_home=worker_home, qwenpaw_working_dir=tmp_path / "qwenpaw", default_workspace_dir=workspace_dir, runtime_config_path=tmp_path / "runtime.yaml", @@ -172,6 +174,9 @@ def test_runtime_updater_applies_inline_prompt_config_to_workspace(tmp_path: Pat assert (workspace_dir / "IDENTITY.md").read_text(encoding="utf-8") == "Frontend specialist\n" assert (workspace_dir / "SOUL.md").read_text(encoding="utf-8") == "Build accessible user interfaces\n" assert (workspace_dir / "AGENTS.md").read_text(encoding="utf-8") == "Follow the project workflow\n" + assert (worker_home / "IDENTITY.md").read_text(encoding="utf-8") == "Frontend specialist\n" + assert (worker_home / "SOUL.md").read_text(encoding="utf-8") == "Build accessible user interfaces\n" + assert (worker_home / "AGENTS.md").read_text(encoding="utf-8") == "Follow the project workflow\n" def _install_fake_qwenpaw_mcp_config(monkeypatch): diff --git a/scripts/export-debug-log.py b/scripts/export-debug-log.py index 82a2105ed..47050a5f2 100644 --- a/scripts/export-debug-log.py +++ b/scripts/export-debug-log.py @@ -21,11 +21,14 @@ ├── summary.txt ├── matrix-messages/ │ └── RoomName_!roomid.jsonl - └── agent-sessions/ + ├── agent-sessions/ ├── agentteams-manager/ │ └── {session-id}.jsonl └── agentteams-worker-xxx/ └── {session-key}.jsonl + └── container-logs/ + ├── agentteams-worker-xxx.log + └── agentteams-worker-xxx.state.json """ import argparse @@ -156,6 +159,82 @@ def list_agentteams_containers() -> list[str]: return list(dict.fromkeys(names)) +# --------------------------------------------------------------------------- +# Container diagnostics +# --------------------------------------------------------------------------- + +def _docker_run(*args: str) -> subprocess.CompletedProcess[str]: + try: + return subprocess.run( + ["docker", *args], + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.SubprocessError) as exc: + return subprocess.CompletedProcess( + ["docker", *args], + 1, + "", + f"{type(exc).__name__}: {exc}", + ) + + +def export_container_logs(out_dir: Path, since_epoch: float, redact: bool, + container_filter: str | None) -> int: + """Export Docker state and logs for all AgentTeams containers.""" + result = _docker_run("ps", "-a", "--format", "{{.Names}}", "--filter", "name=agentteams-") + containers = [name.strip() for name in result.stdout.splitlines() if name.strip()] + if container_filter: + containers = [name for name in containers if container_filter in name] + if not containers: + print(" [containers] No matching AgentTeams containers found") + return 0 + + out_dir.mkdir(parents=True, exist_ok=True) + since = datetime.fromtimestamp(since_epoch, tz=timezone.utc).isoformat() + + for container in containers: + state_result = _docker_run("inspect", "--format={{json .State}}", container) + image_result = _docker_run("inspect", "--format={{json .Config.Image}}", container) + restart_result = _docker_run("inspect", "--format={{.RestartCount}}", container) + try: + state = json.loads(state_result.stdout) + except (json.JSONDecodeError, TypeError): + state = {"inspect_error": state_result.stderr.strip() or state_result.stdout.strip()} + try: + image = json.loads(image_result.stdout) + except (json.JSONDecodeError, TypeError): + image = image_result.stdout.strip() + try: + restart_count = int(restart_result.stdout.strip()) + except ValueError: + restart_count = None + + diagnostic = { + "container": container, + "image": image, + "restart_count": restart_count, + "state": state, + } + if redact: + diagnostic = redact_json_strings(diagnostic) + filename = sanitize_filename(container) + (out_dir / f"{filename}.state.json").write_text( + json.dumps(diagnostic, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + logs_result = _docker_run("logs", "--timestamps", "--since", since, container) + logs = logs_result.stdout + logs_result.stderr + if redact: + logs = redact_pii(logs) + (out_dir / f"{filename}.log").write_text(logs, encoding="utf-8") + print(f" {container}: state + {len(logs.splitlines())} log lines") + + return len(containers) + + # --------------------------------------------------------------------------- # Matrix messages export # --------------------------------------------------------------------------- @@ -751,6 +830,17 @@ def main(): sessions_dir.rmdir() print() + # --- Container diagnostics --- + print("=== Container Diagnostics ===") + container_logs_dir = run_dir / "container-logs" + containers = export_container_logs( + container_logs_dir, since_epoch, redact, + container_filter=args.container, + ) + if containers == 0 and container_logs_dir.exists() and not any(container_logs_dir.iterdir()): + container_logs_dir.rmdir() + print() + # --- Summary --- summary = ( f"AgentTeams Debug Log\n" @@ -760,10 +850,15 @@ def main(): f"\n" f"Matrix messages: {messages} messages from {rooms} rooms\n" f"Agent sessions: {events} events from {sessions} sessions\n" + f"Container diagnostics: {containers} containers\n" ) (run_dir / "summary.txt").write_text(summary) - print(f"Done. {messages} messages from {rooms} rooms, {events} events from {sessions} sessions") + print( + f"Done. {messages} messages from {rooms} rooms, " + f"{events} events from {sessions} sessions, " + f"{containers} container diagnostics" + ) print(f"Output: {run_dir.resolve()}") diff --git a/scripts/tests/test_export_debug_log.py b/scripts/tests/test_export_debug_log.py index 78a32acdd..c6dd75773 100644 --- a/scripts/tests/test_export_debug_log.py +++ b/scripts/tests/test_export_debug_log.py @@ -1,8 +1,11 @@ #!/usr/bin/env python3 import importlib.util +import json +import subprocess import unittest from pathlib import Path +from unittest import mock SCRIPT = Path(__file__).resolve().parents[1] / "export-debug-log.py" @@ -91,5 +94,53 @@ def test_redacts_message_metadata(self): self.assertEqual(record["relates_to"]["token"], "****") +class ExportContainerLogsTest(unittest.TestCase): + def test_exports_container_state_and_redacted_logs(self): + def run_docker(command, **_kwargs): + if command[1:3] == ["ps", "-a"]: + return subprocess.CompletedProcess(command, 0, "agentteams-worker-alice\n", "") + if command[1:3] == ["inspect", "--format={{json .State}}"]: + return subprocess.CompletedProcess( + command, + 0, + '{"Status":"running","ExitCode":0,"OOMKilled":false}\n', + "", + ) + if command[1:3] == ["inspect", "--format={{json .Config.Image}}"]: + return subprocess.CompletedProcess(command, 0, '"agentteams/qwenpaw-worker:test"\n', "") + if command[1:3] == ["inspect", "--format={{.RestartCount}}"]: + return subprocess.CompletedProcess(command, 0, "2\n", "") + if command[1] == "logs": + return subprocess.CompletedProcess( + command, + 0, + "qwenpaw app exited api_key=secret-value\n", + "", + ) + raise AssertionError(f"unexpected docker command: {command}") + + with mock.patch.object(export_debug_log.subprocess, "run", side_effect=run_docker): + from tempfile import TemporaryDirectory + + with TemporaryDirectory() as tmp: + out_dir = Path(tmp) + count = export_debug_log.export_container_logs( + out_dir, + since_epoch=1_700_000_000, + redact=True, + container_filter="worker-alice", + ) + + self.assertEqual(count, 1) + state = json.loads((out_dir / "agentteams-worker-alice.state.json").read_text()) + self.assertEqual(state["restart_count"], 2) + self.assertEqual(state["image"], "agentteams/qwenpaw-worker:test") + self.assertEqual(state["state"]["Status"], "running") + self.assertEqual( + (out_dir / "agentteams-worker-alice.log").read_text(), + "qwenpaw app exited api_key=****\n", + ) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test-26-qwenpaw-teamharness-plugin-mode.sh b/tests/test-26-qwenpaw-teamharness-plugin-mode.sh index e49caa415..009328d64 100755 --- a/tests/test-26-qwenpaw-teamharness-plugin-mode.sh +++ b/tests/test-26-qwenpaw-teamharness-plugin-mode.sh @@ -652,7 +652,7 @@ with zipfile.ZipFile(zip_path) as archive: assert any(name.endswith("/teamharness/plugin.yaml") for name in names) assert any(name.endswith("/teamharness/prompts/team/TEAMS.md") for name in names) assert any(name.endswith("/teamharness/mcp/server.py") for name in names) - assert not any("agentteam" in name for name in names) + assert not any("agentteam" in Path(name).parts for name in names) print("ok") PY ) @@ -723,6 +723,7 @@ LEADER_DM=$(echo "${TEAM_JSON}" | jq -r '.status.leaderDMRoomID // empty') assert_not_empty "${TEAM_ROOM}" "Team Room ID available" assert_not_empty "${LEADER_DM}" "Leader DM Room ID available" +STARTUP_READY=1 for member in "${TEST_LEADER}" "${TEST_WORKER}"; do MEMBER_JSON=$(_wait_k8s_jq "workers" "${member}" '.status.roomID and .status.matrixUserID' 240 2>/dev/null || echo "{}") if echo "${MEMBER_JSON}" | jq -e '.status.roomID and .status.matrixUserID' >/dev/null 2>&1; then @@ -735,11 +736,15 @@ for member in "${TEST_LEADER}" "${TEST_WORKER}"; do log_pass "Member ${member} is Running" else log_fail "Member ${member} did not reach Running" + dump_diagnostics worker "${member}" + STARTUP_READY=0 fi if wait_for_worker_container "${member}" 240; then log_pass "Container for ${member} is running" else log_fail "Container for ${member} did not start" + dump_diagnostics worker "${member}" + STARTUP_READY=0 fi done @@ -759,11 +764,20 @@ if _container_has_cmdline "${LEADER_CONTAINER}" "qwenpaw app --host"; then log_pass "Leader qwenpaw app process is running" else log_fail "Leader qwenpaw app process is not running" + STARTUP_READY=0 fi if _container_has_cmdline "${WORKER_CONTAINER}" "qwenpaw app --host"; then log_pass "Worker qwenpaw app process is running" else log_fail "Worker qwenpaw app process is not running" + STARTUP_READY=0 +fi + +if [ "${STARTUP_READY}" -ne 1 ]; then + _dump_debug_snapshot + test_teardown "26-qwenpaw-teamharness-plugin-mode" + test_summary + exit $? fi # ============================================================ From b31af0deb95c2ca674f1f5bc455a2147b3b2f331 Mon Sep 17 00:00:00 2001 From: "zunfei.lzf" Date: Sun, 26 Jul 2026 10:43:35 +0800 Subject: [PATCH 05/37] fix(controller): grant team members shared storage access Change-Id: Ieeed7e69bf3b23e3f03aaa5168e4fe807040c1fe --- .../internal/controller/team_controller.go | 15 +++++++++++++++ .../internal/controller/team_controller_test.go | 10 ++++++++++ changelog/current.md | 1 + 3 files changed, 26 insertions(+) diff --git a/agentteams-controller/internal/controller/team_controller.go b/agentteams-controller/internal/controller/team_controller.go index 9cee12604..a69c26395 100644 --- a/agentteams-controller/internal/controller/team_controller.go +++ b/agentteams-controller/internal/controller/team_controller.go @@ -393,6 +393,21 @@ func (r *TeamReconciler) reconcileTeam(ctx context.Context, t *v1beta1.Team, pat if err := r.Deployer.EnsureTeamStorage(ctx, teamRuntimeName); err != nil { logger.Error(err, "team shared storage init failed (non-fatal)", "name", t.Name, "teamName", teamRuntimeName) } + for _, member := range members { + if _, err := r.Provisioner.RefreshWorkerCredentials( + ctx, + member.ref.Name, + member.runtimeName, + teamRuntimeName, + ); err != nil { + return r.failTeam( + ctx, + t, + patchBase, + fmt.Sprintf("refresh team storage access for %s: %v", member.runtimeName, err), + ) + } + } // 5. Coordination context + heartbeat injection teamWorkerEntries := teamWorkerEntries(members, leaderRef.Name) diff --git a/agentteams-controller/internal/controller/team_controller_test.go b/agentteams-controller/internal/controller/team_controller_test.go index e5f723e74..d185f72ca 100644 --- a/agentteams-controller/internal/controller/team_controller_test.go +++ b/agentteams-controller/internal/controller/team_controller_test.go @@ -847,6 +847,16 @@ func TestReconcileTeamTeamReferences_RoleAwareChannelPolicy(t *testing.T) { t.Fatalf("reconcileTeam: %v", err) } + refreshedTeamAccess := map[string]string{} + for _, call := range provisioner.Calls.RefreshWorkerCredentials { + refreshedTeamAccess[call.WorkerName] = call.TeamName + } + for _, workerName := range []string{"lead", "dev", "qa"} { + if got := refreshedTeamAccess[workerName]; got != "team-a" { + t.Errorf("RefreshWorkerCredentials team for %q = %q, want team-a", workerName, got) + } + } + if len(deployer.Calls.SyncTeamLeaderAssets) != 1 { t.Fatalf("SyncTeamLeaderAssets calls=%d, want 1", len(deployer.Calls.SyncTeamLeaderAssets)) } diff --git a/changelog/current.md b/changelog/current.md index b26bddb4f..4ff6c66e4 100644 --- a/changelog/current.md +++ b/changelog/current.md @@ -6,6 +6,7 @@ Record image-affecting changes to `manager/`, `worker/`, `copaw/`, `hermes/`, `o **Bug Fixes** +- **Team member shared storage access**: Refresh every referenced member's storage policy with the effective Team name before deploying Team runtime configuration. - **QwenPaw inline prompt compatibility**: Project Worker identity, SOUL, and AGENTS content through runtime desired state and apply it to both the native QwenPaw workspace and the Worker root storage contract. - **Integration failure diagnostics**: Export AgentTeams container state and timestamped logs with CI artifacts, and stop the QwenPaw TeamHarness shard after startup failures instead of hiding the cause behind cascading timeouts. - **QwenPaw local startup readiness**: Select and propagate the QwenPaw Worker image across install backends, then wait for the runtime-owned `runtime/runtime.yaml` object before creating a local Docker Worker. From 4ed2b3153f3e5942eb861eefeba38d7451f2dd23 Mon Sep 17 00:00:00 2001 From: "zunfei.lzf" Date: Sun, 26 Jul 2026 11:22:50 +0800 Subject: [PATCH 06/37] fix(qwenpaw): allow read-only AgentSpec package fetch Change-Id: I9b2094e39e7dfc12437aedf347199ace79de1f5b --- .../internal/accessresolver/defaults.go | 28 +++++++- .../internal/accessresolver/resolver_test.go | 41 ++++++------ .../internal/credentials/sts_test.go | 8 +-- .../internal/oss/client_test.go | 19 +++++- .../internal/oss/minio_admin.go | 64 +++++++++++++------ changelog/current.md | 4 +- qwenpaw/src/qwenpaw_worker/worker.py | 12 +++- qwenpaw/tests/test_worker_lifecycle.py | 8 ++- 8 files changed, 130 insertions(+), 54 deletions(-) diff --git a/agentteams-controller/internal/accessresolver/defaults.go b/agentteams-controller/internal/accessresolver/defaults.go index 9a1e90388..9d75f085e 100644 --- a/agentteams-controller/internal/accessresolver/defaults.go +++ b/agentteams-controller/internal/accessresolver/defaults.go @@ -26,7 +26,8 @@ import ( // applied when a Worker CR omits spec.accessEntries. Mirrors the // embedded-mode MinIO policy produced by oss.MinIOAdminClient.buildWorkerPolicy // (for a standalone worker): read/write/list/delete on the worker's own -// agent prefix and on the shared prefix, scoped to the workspace bucket. +// agent prefix and on the shared prefix, plus read/list on centrally uploaded +// AgentSpec packages, scoped to the workspace bucket. // // The returned entries still contain the ${self.name} template — they // are resolved by Resolver.ResolveForCaller before leaving the @@ -46,6 +47,17 @@ func DefaultEntriesForWorker() []v1beta1.AccessEntry { }, }), }, + { + Service: credprovider.ServiceObjectStorage, + Permissions: []string{"read", "list"}, + Scope: jsonObj(map[string]any{ + "bucketRef": "workspace", + "prefixes": []any{ + "agentteams-config/packages/", + "agentteams-config/packages/*", + }, + }), + }, } } @@ -54,7 +66,8 @@ func DefaultEntriesForWorker() []v1beta1.AccessEntry { // Mirrors the embedded-mode policy produced by // oss.MinIOAdminClient.buildWorkerPolicy when teamName != "": read/ // write/list/delete on the member's own agent prefix, on the shared -// prefix, and on the team-scoped prefix. +// prefix, and on the team-scoped prefix, plus read/list on centrally uploaded +// AgentSpec packages. // // Leader and team workers share the same default scope — the leader is // not elevated with cross-member access. Cross-member collaboration is @@ -80,6 +93,17 @@ func DefaultEntriesForTeamMember() []v1beta1.AccessEntry { }, }), }, + { + Service: credprovider.ServiceObjectStorage, + Permissions: []string{"read", "list"}, + Scope: jsonObj(map[string]any{ + "bucketRef": "workspace", + "prefixes": []any{ + "agentteams-config/packages/", + "agentteams-config/packages/*", + }, + }), + }, } } diff --git a/agentteams-controller/internal/accessresolver/resolver_test.go b/agentteams-controller/internal/accessresolver/resolver_test.go index 4bba58b52..d4f76cc17 100644 --- a/agentteams-controller/internal/accessresolver/resolver_test.go +++ b/agentteams-controller/internal/accessresolver/resolver_test.go @@ -52,12 +52,8 @@ func TestResolveWorker_DefaultEntries(t *testing.T) { if session != "agentteams-worker-alice" { t.Fatalf("session = %q", session) } - // Standalone workers now default to a single object-storage entry - // that folds agents// + agents//* + shared/ + shared/* - // together, mirroring the - // embedded MinIO policy which grants both prefixes RW. - if len(entries) != 1 { - t.Fatalf("expected 1 default entry, got %d", len(entries)) + if len(entries) != 2 { + t.Fatalf("expected RW workspace and read-only package entries, got %d", len(entries)) } e := entries[0] if e.Service != credprovider.ServiceObjectStorage { @@ -74,6 +70,15 @@ func TestResolveWorker_DefaultEntries(t *testing.T) { if !hasAllPerms(e.Permissions, "read", "write", "list", "delete") { t.Fatalf("expected RW shared/* permissions, got %+v", e.Permissions) } + packageEntry := entries[1] + if !hasPrefix(packageEntry.Scope.Prefixes, "agentteams-config/packages/*") { + t.Fatalf("expected AgentSpec package prefix, got %+v", packageEntry.Scope.Prefixes) + } + if !hasAllPerms(packageEntry.Permissions, "read", "list") || + hasAllPerms(packageEntry.Permissions, "write") || + hasAllPerms(packageEntry.Permissions, "delete") { + t.Fatalf("AgentSpec package permissions must be read/list only, got %+v", packageEntry.Permissions) + } } func hasPrefix(prefixes []string, want string) bool { @@ -235,7 +240,7 @@ func TestResolve_AIGatewayHappyPath(t *testing.T) { if err != nil { t.Fatalf("resolve: %v", err) } - if len(entries) != 2 { + if len(entries) != 3 { t.Fatalf("got %d entries", len(entries)) } if entryForService(entries, credprovider.ServiceObjectStorage) == nil { @@ -276,7 +281,7 @@ func TestResolve_AIRegistryDefaultResources(t *testing.T) { if err != nil { t.Fatalf("resolve: %v", err) } - if len(entries) != 2 { + if len(entries) != 3 { t.Fatalf("got %d entries", len(entries)) } if entryForService(entries, credprovider.ServiceObjectStorage) == nil { @@ -575,8 +580,8 @@ func TestResolveTeamLeader_DefaultEntries(t *testing.T) { if session != "agentteams-worker-lead" { t.Fatalf("session = %q, want agentteams-worker-lead", session) } - if len(entries) != 1 { - t.Fatalf("expected 1 default entry, got %d", len(entries)) + if len(entries) != 2 { + t.Fatalf("expected 2 default entries, got %d", len(entries)) } e := entries[0] if e.Scope.Bucket != "agentteams-test" { @@ -613,8 +618,8 @@ func TestResolveTeamLeader_DefaultEntriesUseRuntimeWorkerName(t *testing.T) { if session != "agentteams-worker-lead" { t.Fatalf("session = %q, want agentteams-worker-lead", session) } - if len(entries) != 1 { - t.Fatalf("expected 1 default entry, got %d", len(entries)) + if len(entries) != 2 { + t.Fatalf("expected 2 default entries, got %d", len(entries)) } got := entries[0].Scope.Prefixes if !hasPrefix(got, "agents/runtime-lead/") { @@ -672,8 +677,8 @@ func TestResolveTeamWorker_DefaultEntries(t *testing.T) { if session != "agentteams-worker-w1" { t.Fatalf("session = %q", session) } - if len(entries) != 1 { - t.Fatalf("expected 1 default entry, got %d", len(entries)) + if len(entries) != 2 { + t.Fatalf("expected 2 default entries, got %d", len(entries)) } e := entries[0] for _, want := range []string{"agents/w1/", "agents/w1/*", "shared/", "shared/*", "teams/alpha/", "teams/alpha/*"} { @@ -710,8 +715,8 @@ func TestResolveTeamWorker_DefaultEntriesUseRuntimeWorkerName(t *testing.T) { if session != "agentteams-worker-w1" { t.Fatalf("session = %q, want agentteams-worker-w1", session) } - if len(entries) != 1 { - t.Fatalf("expected 1 default entry, got %d", len(entries)) + if len(entries) != 2 { + t.Fatalf("expected 2 default entries, got %d", len(entries)) } got := entries[0].Scope.Prefixes if !hasPrefix(got, "agents/runtime-w1/*") { @@ -777,8 +782,8 @@ func TestResolveTeamMember_TeamCRMissing(t *testing.T) { if err != nil { t.Fatalf("resolve: %v", err) } - if len(entries) != 1 { - t.Fatalf("expected 1 default entry, got %d", len(entries)) + if len(entries) != 2 { + t.Fatalf("expected 2 default entries, got %d", len(entries)) } got := entries[0].Scope.Prefixes for _, want := range []string{"agents/ghost-worker/*", "shared/*", "teams/ghost/*"} { diff --git a/agentteams-controller/internal/credentials/sts_test.go b/agentteams-controller/internal/credentials/sts_test.go index fe24a75ed..f5a5ce85e 100644 --- a/agentteams-controller/internal/credentials/sts_test.go +++ b/agentteams-controller/internal/credentials/sts_test.go @@ -96,12 +96,8 @@ func TestIssueForCaller_WorkerDefaultEntries(t *testing.T) { if len(fake.lastReq.SessionName) != 36 { t.Fatalf("session length = %d, want 36", len(fake.lastReq.SessionName)) } - // Standalone workers now default to a single object-storage entry - // covering agents// + agents//* + shared/ + shared/* - // (both RW), mirroring the - // embedded MinIO policy. - if len(fake.lastReq.Entries) != 1 { - t.Fatalf("expected 1 default entry, got %d", len(fake.lastReq.Entries)) + if len(fake.lastReq.Entries) != 2 { + t.Fatalf("expected RW workspace and read-only package entries, got %d", len(fake.lastReq.Entries)) } got := fake.lastReq.Entries[0] if got.Scope.Bucket != "test-bucket" { diff --git a/agentteams-controller/internal/oss/client_test.go b/agentteams-controller/internal/oss/client_test.go index 500e549b7..e0d717a24 100644 --- a/agentteams-controller/internal/oss/client_test.go +++ b/agentteams-controller/internal/oss/client_test.go @@ -67,8 +67,8 @@ func TestMinIOAdminClient_BuildWorkerPolicy(t *testing.T) { if policy.Version != "2012-10-17" { t.Errorf("Version = %q", policy.Version) } - if len(policy.Statement) != 3 { - t.Fatalf("expected 3 statements, got %d", len(policy.Statement)) + if len(policy.Statement) != 4 { + t.Fatalf("expected 4 statements, got %d", len(policy.Statement)) } locationStmt := policy.Statement[0] @@ -137,6 +137,9 @@ func TestMinIOAdminClient_BuildWorkerPolicy(t *testing.T) { if !hasTeamDir { t.Errorf("expected team directory prefix in list conditions: %v", prefixes) } + if !stringSliceContains(prefixes, "agentteams-config/packages/*") { + t.Errorf("expected AgentSpec package prefix in list conditions: %v", prefixes) + } // Verify team resource in RW statement rwStmt := policy.Statement[2] @@ -191,6 +194,18 @@ func TestMinIOAdminClient_BuildWorkerPolicy(t *testing.T) { if !hasTeamDirResource { t.Errorf("expected team directory resource in RW statement: %v", rwStmt.Resource) } + packageResource := "arn:aws:s3:::agentteams-storage/agentteams-config/packages/*" + if stringSliceContains(rwStmt.Resource, packageResource) { + t.Errorf("AgentSpec packages must not be writable: %v", rwStmt.Resource) + } + + readStmt := policy.Statement[3] + if len(readStmt.Action) != 1 || readStmt.Action[0] != "s3:GetObject" { + t.Errorf("AgentSpec package statement actions = %v, want GetObject only", readStmt.Action) + } + if !stringSliceContains(readStmt.Resource, packageResource) { + t.Errorf("expected read-only AgentSpec package resource: %v", readStmt.Resource) + } } func TestMinIOAdminClient_BuildWorkerPolicyNoTeam(t *testing.T) { diff --git a/agentteams-controller/internal/oss/minio_admin.go b/agentteams-controller/internal/oss/minio_admin.go index 211d36e56..457310abe 100644 --- a/agentteams-controller/internal/oss/minio_admin.go +++ b/agentteams-controller/internal/oss/minio_admin.go @@ -204,6 +204,14 @@ func (c *MinIOAdminClient) buildWorkerPolicy(workerName, bucket, teamName string fmt.Sprintf("arn:aws:s3:::%s/manager/", bucket), fmt.Sprintf("arn:aws:s3:::%s/manager/*", bucket), ) + } else { + listPrefixes = append(listPrefixes, + "agentteams-config", + "agentteams-config/", + "agentteams-config/packages", + "agentteams-config/packages/", + "agentteams-config/packages/*", + ) } if teamName != "" { @@ -221,30 +229,44 @@ func (c *MinIOAdminClient) buildWorkerPolicy(workerName, bucket, teamName string ) } - return s3Policy{ - Version: "2012-10-17", - Statement: []s3PolicyStatement{ - { - Effect: "Allow", - Action: []string{"s3:GetBucketLocation"}, - Resource: []string{fmt.Sprintf("arn:aws:s3:::%s", bucket)}, - }, - { - Effect: "Allow", - Action: []string{"s3:ListBucket"}, - Resource: []string{fmt.Sprintf("arn:aws:s3:::%s", bucket)}, - Condition: map[string]interface{}{ - "StringLike": map[string]interface{}{ - "s3:prefix": listPrefixes, - }, + statements := []s3PolicyStatement{ + { + Effect: "Allow", + Action: []string{"s3:GetBucketLocation"}, + Resource: []string{fmt.Sprintf("arn:aws:s3:::%s", bucket)}, + }, + { + Effect: "Allow", + Action: []string{"s3:ListBucket"}, + Resource: []string{fmt.Sprintf("arn:aws:s3:::%s", bucket)}, + Condition: map[string]interface{}{ + "StringLike": map[string]interface{}{ + "s3:prefix": listPrefixes, }, }, - { - Effect: "Allow", - Action: []string{"s3:GetObject", "s3:PutObject", "s3:DeleteObject"}, - Resource: rwResources, - }, }, + { + Effect: "Allow", + Action: []string{"s3:GetObject", "s3:PutObject", "s3:DeleteObject"}, + Resource: rwResources, + }, + } + if !isManager { + statements = append(statements, + s3PolicyStatement{ + Effect: "Allow", + Action: []string{"s3:GetObject"}, + Resource: []string{ + fmt.Sprintf("arn:aws:s3:::%s/agentteams-config/packages", bucket), + fmt.Sprintf("arn:aws:s3:::%s/agentteams-config/packages/", bucket), + fmt.Sprintf("arn:aws:s3:::%s/agentteams-config/packages/*", bucket), + }, + }, + ) + } + return s3Policy{ + Version: "2012-10-17", + Statement: statements, } } diff --git a/changelog/current.md b/changelog/current.md index 4ff6c66e4..4074b1f20 100644 --- a/changelog/current.md +++ b/changelog/current.md @@ -6,9 +6,9 @@ Record image-affecting changes to `manager/`, `worker/`, `copaw/`, `hermes/`, `o **Bug Fixes** -- **Team member shared storage access**: Refresh every referenced member's storage policy with the effective Team name before deploying Team runtime configuration. +- **QwenPaw package and Team storage access**: Refresh every referenced member's storage policy with the effective Team name, and grant Workers read-only access to centrally uploaded AgentSpec packages. - **QwenPaw inline prompt compatibility**: Project Worker identity, SOUL, and AGENTS content through runtime desired state and apply it to both the native QwenPaw workspace and the Worker root storage contract. -- **Integration failure diagnostics**: Export AgentTeams container state and timestamped logs with CI artifacts, and stop the QwenPaw TeamHarness shard after startup failures instead of hiding the cause behind cascading timeouts. +- **Integration failure diagnostics**: Export AgentTeams container state and timestamped logs with CI artifacts, classify QwenPaw startup errors without exposing their message contents, and stop the TeamHarness shard after startup failures instead of hiding the cause behind cascading timeouts. - **QwenPaw local startup readiness**: Select and propagate the QwenPaw Worker image across install backends, then wait for the runtime-owned `runtime/runtime.yaml` object before creating a local Docker Worker. - **Team Worker room boundary convergence**: Remove Manager again after standalone Worker infrastructure reconciliation restores regular Team Worker personal-room membership. ([b5b0add](https://github.com/agentscope-ai/AgentTeams/commit/b5b0add)) - **Team Worker reference enforcement**: Keep referenced Worker CRs protected during direct deletion and reject Team API members whose required role is empty. ([d96f1ed](https://github.com/agentscope-ai/AgentTeams/commit/d96f1ed)) diff --git a/qwenpaw/src/qwenpaw_worker/worker.py b/qwenpaw/src/qwenpaw_worker/worker.py index eac36313a..ec64a000a 100644 --- a/qwenpaw/src/qwenpaw_worker/worker.py +++ b/qwenpaw/src/qwenpaw_worker/worker.py @@ -54,6 +54,15 @@ def _redact_url_userinfo(value: str) -> str: return f"{scheme}://@{rest.split('@', 1)[1]}" +def _safe_error_code(exc: Exception) -> str: + message = str(exc).lower() + if "access denied" in message: + return "storage_access_denied" + if "agent package" in message: + return "agent_package_failed" + return type(exc).__name__ + + class Worker: def __init__(self, config: WorkerConfig) -> None: self.config = config @@ -273,11 +282,12 @@ def _log_worker_stage_complete(self, stage: str, started_at: float, **fields: ob def _log_worker_stage_failed(self, stage: str, started_at: float, exc: Exception, **fields: object) -> None: logger.warning( - "startup component=worker stage=%s event=failed worker=%s duration_ms=%s error_type=%s %s", + "startup component=worker stage=%s event=failed worker=%s duration_ms=%s error_type=%s error_code=%s %s", stage, self.config.worker_name, _duration_ms(started_at), type(exc).__name__, + _safe_error_code(exc), _log_fields(**fields), ) diff --git a/qwenpaw/tests/test_worker_lifecycle.py b/qwenpaw/tests/test_worker_lifecycle.py index 3576c2452..a6030527a 100644 --- a/qwenpaw/tests/test_worker_lifecycle.py +++ b/qwenpaw/tests/test_worker_lifecycle.py @@ -1111,6 +1111,7 @@ def fail_config(_self): async def test_hot_update_failure_does_not_relabel_qwenpaw_config_unready( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, ) -> None: config = _config(tmp_path) _runtime_yaml(config.runtime_config_path) @@ -1121,9 +1122,10 @@ async def test_hot_update_failure_does_not_relabel_qwenpaw_config_unready( monkeypatch.setattr("qwenpaw_worker.worker.Worker._prepare_default_plugins", lambda _self: None) def fail_update(_self, runtime_config=None, force=False, reapply_adapter=True): - raise RuntimeError("agent package failed") + raise RuntimeError("fetch oss agent package failed: Access Denied secret-token-value") monkeypatch.setattr("qwenpaw_worker.update.RuntimeUpdater.apply_once", fail_update) + caplog.set_level(logging.INFO, logger="qwenpaw_worker.worker") worker = Worker(config) @@ -1131,7 +1133,9 @@ def fail_update(_self, runtime_config=None, force=False, reapply_adapter=True): data = json.loads((config.qwenpaw_working_dir / "heartbeat.json").read_text(encoding="utf-8")) assert data["status"] == "not_ready" - assert data["message"] == "agent package failed" + assert data["message"].startswith("fetch oss agent package failed") + assert "error_code=storage_access_denied" in caplog.text + assert "secret-token-value" not in caplog.text def test_hot_update_apply_does_not_replace_qwenpaw_process(tmp_path: Path) -> None: From dbef186eedc7c238e3732ac22585a2845bab1e64 Mon Sep 17 00:00:00 2001 From: "zunfei.lzf" Date: Sun, 26 Jul 2026 12:03:52 +0800 Subject: [PATCH 07/37] fix(controller): preserve team storage policy Change-Id: I04a62daf94efad6d0445dce93a56cf897530c25e --- agentteams-controller/api/v1beta1/types.go | 4 ++ .../internal/controller/member_reconcile.go | 5 +- .../controller/member_reconcile_test.go | 24 +++++++++ .../internal/controller/team_controller.go | 37 +++++++++++++- .../controller/team_controller_test.go | 51 +++++++++++++++++++ .../internal/controller/worker_controller.go | 25 +++++++++ .../controller/worker_controller_test.go | 41 +++++++++++++++ changelog/current.md | 2 +- 8 files changed, 185 insertions(+), 4 deletions(-) diff --git a/agentteams-controller/api/v1beta1/types.go b/agentteams-controller/api/v1beta1/types.go index 4f4313848..323bb5a1f 100644 --- a/agentteams-controller/api/v1beta1/types.go +++ b/agentteams-controller/api/v1beta1/types.go @@ -43,6 +43,10 @@ const LabelWorkerEdgeUUID = "agentteams.io/worker-edge-uuid" // re-issues credentials and updates the annotation to match. const AnnotationEdgeAppliedUUID = "agentteams.io/edge-applied-uuid" +// AnnotationWorkerTeamName records the effective Team identity for a referenced +// Worker so independent Worker reconciles preserve its scoped team storage access. +const AnnotationWorkerTeamName = "agentteams.io/team-name" + // AccessEntry declares one cloud-permission grant under a logical // service. v1 supported services: "object-storage", "ai-gateway", "ai-registry", "schedulerx3". // diff --git a/agentteams-controller/internal/controller/member_reconcile.go b/agentteams-controller/internal/controller/member_reconcile.go index 21faeb88d..ec454029b 100644 --- a/agentteams-controller/internal/controller/member_reconcile.go +++ b/agentteams-controller/internal/controller/member_reconcile.go @@ -80,6 +80,7 @@ func (r MemberRole) String() string { return string(r) } type MemberContext struct { Name string // Kubernetes resource identity (CR/Pod/SA key) RuntimeName string // business/runtime identity (Matrix/OSS/room alias key) + TeamName string // effective Team identity used for scoped storage access Namespace string Role MemberRole Spec v1beta1.WorkerSpec @@ -286,7 +287,7 @@ func ValidateMemberDeployment(m MemberContext) error { // RoomID, and ProvResult into state. func ReconcileMemberInfra(ctx context.Context, d MemberDeps, m MemberContext, state *MemberState) (reconcile.Result, error) { if m.ExistingMatrixUserID != "" { - refreshResult, err := d.Provisioner.RefreshWorkerCredentials(ctx, m.Name, m.RuntimeName, "") + refreshResult, err := d.Provisioner.RefreshWorkerCredentials(ctx, m.Name, m.RuntimeName, m.TeamName) if err != nil { return reconcile.Result{}, fmt.Errorf("refresh credentials: %w", err) } @@ -689,7 +690,7 @@ func createMemberContainer(ctx context.Context, d MemberDeps, m MemberContext, s prov := state.ProvResult if prov == nil || prov.MatrixToken == "" { - refreshResult, err := d.Provisioner.RefreshWorkerCredentials(ctx, m.Name, m.RuntimeName, "") + refreshResult, err := d.Provisioner.RefreshWorkerCredentials(ctx, m.Name, m.RuntimeName, m.TeamName) if err != nil { return reconcile.Result{}, fmt.Errorf("refresh credentials for container: %w", err) } diff --git a/agentteams-controller/internal/controller/member_reconcile_test.go b/agentteams-controller/internal/controller/member_reconcile_test.go index 9d325d247..22cc4aee0 100644 --- a/agentteams-controller/internal/controller/member_reconcile_test.go +++ b/agentteams-controller/internal/controller/member_reconcile_test.go @@ -96,6 +96,30 @@ func TestValidateMemberDeploymentRejectsRemote(t *testing.T) { } } +func TestReconcileMemberInfraPreservesTeamStorageAccess(t *testing.T) { + prov := mocks.NewMockProvisioner() + state := &MemberState{} + + _, err := ReconcileMemberInfra(context.Background(), MemberDeps{ + Provisioner: prov, + }, MemberContext{ + Name: "leader-cr", + RuntimeName: "leader", + TeamName: "team-a", + ExistingMatrixUserID: "@leader:localhost", + ExistingRoomID: "!leader:localhost", + }, state) + if err != nil { + t.Fatalf("ReconcileMemberInfra: %v", err) + } + if len(prov.Calls.RefreshWorkerCredentials) != 1 { + t.Fatalf("RefreshWorkerCredentials calls=%d, want 1", len(prov.Calls.RefreshWorkerCredentials)) + } + if got := prov.Calls.RefreshWorkerCredentials[0].TeamName; got != "team-a" { + t.Fatalf("RefreshWorkerCredentials team=%q, want team-a", got) + } +} + func TestResolveBackendForMember_NoBackendAvailable(t *testing.T) { // An empty registry surfaces an error so callers can decide whether // to skip container management or fail loudly. diff --git a/agentteams-controller/internal/controller/team_controller.go b/agentteams-controller/internal/controller/team_controller.go index a69c26395..029981549 100644 --- a/agentteams-controller/internal/controller/team_controller.go +++ b/agentteams-controller/internal/controller/team_controller.go @@ -393,7 +393,16 @@ func (r *TeamReconciler) reconcileTeam(ctx context.Context, t *v1beta1.Team, pat if err := r.Deployer.EnsureTeamStorage(ctx, teamRuntimeName); err != nil { logger.Error(err, "team shared storage init failed (non-fatal)", "name", t.Name, "teamName", teamRuntimeName) } - for _, member := range members { + for i := range members { + member := &members[i] + if err := r.setWorkerTeamAnnotation(ctx, &member.worker, teamRuntimeName); err != nil { + return r.failTeam( + ctx, + t, + patchBase, + fmt.Sprintf("record team membership for %s: %v", member.runtimeName, err), + ) + } if _, err := r.Provisioner.RefreshWorkerCredentials( ctx, member.ref.Name, @@ -537,6 +546,26 @@ func (r *TeamReconciler) reconcileTeam(ctx context.Context, t *v1beta1.Team, pat return reconcile.Result{RequeueAfter: reconcileInterval}, nil } +func (r *TeamReconciler) setWorkerTeamAnnotation(ctx context.Context, worker *v1beta1.Worker, teamName string) error { + current := "" + if worker.Annotations != nil { + current = worker.Annotations[v1beta1.AnnotationWorkerTeamName] + } + if current == teamName { + return nil + } + base := worker.DeepCopy() + if worker.Annotations == nil { + worker.Annotations = map[string]string{} + } + if teamName == "" { + delete(worker.Annotations, v1beta1.AnnotationWorkerTeamName) + } else { + worker.Annotations[v1beta1.AnnotationWorkerTeamName] = teamName + } + return r.Patch(ctx, worker, client.MergeFrom(base)) +} + func (r *TeamReconciler) resolveTeamMembers(ctx context.Context, t *v1beta1.Team) ([]teamWorkerMember, []string) { members := make([]teamWorkerMember, 0, len(t.Spec.WorkerMembers)) var degradedMsgs []string @@ -754,6 +783,12 @@ func (r *TeamReconciler) detachTeamMember(ctx context.Context, t *v1beta1.Team, logger := log.FromContext(ctx) runtimeName := w.Spec.EffectiveWorkerName(w.Name) runtime := backend.ResolveRuntime(w.Spec.Runtime, r.DefaultRuntime) + if err := r.setWorkerTeamAnnotation(ctx, w, ""); err != nil { + return fmt.Errorf("clear team membership annotation: %w", err) + } + if _, err := r.Provisioner.RefreshWorkerCredentials(ctx, w.Name, runtimeName, ""); err != nil { + return fmt.Errorf("revoke team storage access: %w", err) + } if runtime != backend.RuntimeQwenPaw { if err := r.Deployer.InjectWorkerCoordination(ctx, service.WorkerCoordinationRequest{ WorkerName: runtimeName, diff --git a/agentteams-controller/internal/controller/team_controller_test.go b/agentteams-controller/internal/controller/team_controller_test.go index d185f72ca..55f92ed77 100644 --- a/agentteams-controller/internal/controller/team_controller_test.go +++ b/agentteams-controller/internal/controller/team_controller_test.go @@ -855,6 +855,13 @@ func TestReconcileTeamTeamReferences_RoleAwareChannelPolicy(t *testing.T) { if got := refreshedTeamAccess[workerName]; got != "team-a" { t.Errorf("RefreshWorkerCredentials team for %q = %q, want team-a", workerName, got) } + var worker v1beta1.Worker + if err := c.Get(ctx, client.ObjectKey{Name: workerName, Namespace: "default"}, &worker); err != nil { + t.Fatalf("get worker %q: %v", workerName, err) + } + if got := worker.Annotations[v1beta1.AnnotationWorkerTeamName]; got != "team-a" { + t.Errorf("worker %q team annotation=%q, want team-a", workerName, got) + } } if len(deployer.Calls.SyncTeamLeaderAssets) != 1 { @@ -924,6 +931,50 @@ func TestReconcileTeamTeamReferences_RoleAwareChannelPolicy(t *testing.T) { } } +func TestDetachTeamMemberRevokesPersistedTeamStorageAccess(t *testing.T) { + ctx := context.Background() + worker := &v1beta1.Worker{ + ObjectMeta: metav1.ObjectMeta{ + Name: "worker-a", + Namespace: "default", + Annotations: map[string]string{ + v1beta1.AnnotationWorkerTeamName: "team-a", + }, + }, + } + team := &v1beta1.Team{ + ObjectMeta: metav1.ObjectMeta{Name: "team-a", Namespace: "default"}, + } + scheme := runtime.NewScheme() + if err := v1beta1.AddToScheme(scheme); err != nil { + t.Fatalf("register scheme: %v", err) + } + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(worker.DeepCopy()).Build() + provisioner := mocks.NewMockProvisioner() + r := &TeamReconciler{ + Client: c, + Provisioner: provisioner, + Deployer: mocks.NewMockDeployer(), + } + + if err := r.detachTeamMember(ctx, team, worker); err != nil { + t.Fatalf("detachTeamMember: %v", err) + } + var updated v1beta1.Worker + if err := c.Get(ctx, client.ObjectKeyFromObject(worker), &updated); err != nil { + t.Fatalf("get worker: %v", err) + } + if got := updated.Annotations[v1beta1.AnnotationWorkerTeamName]; got != "" { + t.Fatalf("team annotation=%q, want empty", got) + } + if len(provisioner.Calls.RefreshWorkerCredentials) != 1 { + t.Fatalf("RefreshWorkerCredentials calls=%d, want 1", len(provisioner.Calls.RefreshWorkerCredentials)) + } + if got := provisioner.Calls.RefreshWorkerCredentials[0].TeamName; got != "" { + t.Fatalf("refreshed team=%q, want empty", got) + } +} + func TestReconcileTeamTeamReferences_WorkerNotProvisionedKeepsTeamActive(t *testing.T) { ctx := context.Background() diff --git a/agentteams-controller/internal/controller/worker_controller.go b/agentteams-controller/internal/controller/worker_controller.go index c67b6acd1..184d4ff7c 100644 --- a/agentteams-controller/internal/controller/worker_controller.go +++ b/agentteams-controller/internal/controller/worker_controller.go @@ -198,6 +198,10 @@ func (r *WorkerReconciler) reconcileNormal(ctx context.Context, w *v1beta1.Worke return reconcile.Result{}, err } mctx := r.workerMemberContextWithSpec(w, effectiveSpec, resourceSpec, updateStrategy) + mctx.TeamName, err = r.workerTeamName(ctx, w) + if err != nil { + return reconcile.Result{}, err + } if effectiveSpec.ModelProvider != "" && r.GatewayClient != nil { info, err := r.GatewayClient.ResolveModelProvider(ctx, effectiveSpec.ModelProvider) @@ -311,6 +315,27 @@ func (r *WorkerReconciler) reconcileNormal(ctx context.Context, w *v1beta1.Worke return reconcile.Result{RequeueAfter: requeueAfter}, nil } +func (r *WorkerReconciler) workerTeamName(ctx context.Context, w *v1beta1.Worker) (string, error) { + if teamName := w.Annotations[v1beta1.AnnotationWorkerTeamName]; teamName != "" { + return teamName, nil + } + var teams v1beta1.TeamList + if err := r.List(ctx, &teams, client.InNamespace(w.Namespace)); err != nil { + return "", fmt.Errorf("list teams for worker %q: %w", w.Name, err) + } + sort.Slice(teams.Items, func(i, j int) bool { + return teams.Items[i].Name < teams.Items[j].Name + }) + for _, team := range teams.Items { + for _, member := range team.Spec.WorkerMembers { + if member.Name == w.Name { + return team.Spec.EffectiveTeamName(team.Name), nil + } + } + } + return "", nil +} + // reconcileDelete cleans up all infrastructure for the Worker and then removes // the finalizer. func (r *WorkerReconciler) reconcileDelete(ctx context.Context, w *v1beta1.Worker) (reconcile.Result, error) { diff --git a/agentteams-controller/internal/controller/worker_controller_test.go b/agentteams-controller/internal/controller/worker_controller_test.go index cc7061aa5..dba72ccf4 100644 --- a/agentteams-controller/internal/controller/worker_controller_test.go +++ b/agentteams-controller/internal/controller/worker_controller_test.go @@ -101,6 +101,47 @@ func newWorkerRig(t *testing.T, objs ...client.Object) *workerTestRig { } } +func TestWorkerTeamNameFindsReferencedTeam(t *testing.T) { + worker := &v1beta1.Worker{ObjectMeta: metav1.ObjectMeta{Name: "leader", Namespace: "agents"}} + team := &v1beta1.Team{ + ObjectMeta: metav1.ObjectMeta{Name: "team-cr", Namespace: "agents"}, + Spec: v1beta1.TeamSpec{ + TeamName: "runtime-team", + WorkerMembers: []v1beta1.TeamWorkerRef{ + {Name: "leader", Role: "team_leader"}, + }, + }, + } + rig := newWorkerRig(t, worker, team) + + got, err := rig.r.workerTeamName(context.Background(), worker) + if err != nil { + t.Fatalf("workerTeamName: %v", err) + } + if got != "runtime-team" { + t.Fatalf("workerTeamName=%q, want runtime-team", got) + } +} + +func TestWorkerTeamNameUsesPersistedMembershipAnnotation(t *testing.T) { + worker := &v1beta1.Worker{ObjectMeta: metav1.ObjectMeta{ + Name: "leader", + Namespace: "agents", + Annotations: map[string]string{ + v1beta1.AnnotationWorkerTeamName: "runtime-team", + }, + }} + rig := newWorkerRig(t, worker) + + got, err := rig.r.workerTeamName(context.Background(), worker) + if err != nil { + t.Fatalf("workerTeamName: %v", err) + } + if got != "runtime-team" { + t.Fatalf("workerTeamName=%q, want runtime-team", got) + } +} + type workerTestGateway struct { modelInfo *gateway.ModelProviderInfo modelErr error diff --git a/changelog/current.md b/changelog/current.md index 4074b1f20..1a2e3dea4 100644 --- a/changelog/current.md +++ b/changelog/current.md @@ -6,7 +6,7 @@ Record image-affecting changes to `manager/`, `worker/`, `copaw/`, `hermes/`, `o **Bug Fixes** -- **QwenPaw package and Team storage access**: Refresh every referenced member's storage policy with the effective Team name, and grant Workers read-only access to centrally uploaded AgentSpec packages. +- **QwenPaw package and Team storage access**: Preserve referenced members' effective Team name across independent Worker reconciles, revoke it on detach, and grant Workers read-only access to centrally uploaded AgentSpec packages. - **QwenPaw inline prompt compatibility**: Project Worker identity, SOUL, and AGENTS content through runtime desired state and apply it to both the native QwenPaw workspace and the Worker root storage contract. - **Integration failure diagnostics**: Export AgentTeams container state and timestamped logs with CI artifacts, classify QwenPaw startup errors without exposing their message contents, and stop the TeamHarness shard after startup failures instead of hiding the cause behind cascading timeouts. - **QwenPaw local startup readiness**: Select and propagate the QwenPaw Worker image across install backends, then wait for the runtime-owned `runtime/runtime.yaml` object before creating a local Docker Worker. From fea0779dacc9d82aaa8170b54182a552001d4ec3 Mon Sep 17 00:00:00 2001 From: "zunfei.lzf" Date: Sun, 26 Jul 2026 12:37:48 +0800 Subject: [PATCH 08/37] fix(controller): update MinIO policies without access gaps Change-Id: I7af2aece15ab2c733754beee59e31d377ca1d810 --- .../internal/oss/client_test.go | 10 ++++------ .../internal/oss/minio_admin.go | 19 ++++++++++--------- changelog/current.md | 2 +- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/agentteams-controller/internal/oss/client_test.go b/agentteams-controller/internal/oss/client_test.go index e0d717a24..578cd306c 100644 --- a/agentteams-controller/internal/oss/client_test.go +++ b/agentteams-controller/internal/oss/client_test.go @@ -274,14 +274,14 @@ func TestMinIOAdminClient_BuildManagerPolicy(t *testing.T) { } } -func TestMinIOAdminClient_EnsurePolicyDetachesBeforeReplace(t *testing.T) { +func TestMinIOAdminClient_EnsurePolicyOverwritesWithoutAccessGap(t *testing.T) { dir := t.TempDir() argsPath := filepath.Join(dir, "args") mcPath := filepath.Join(dir, "mc") script := `#!/bin/sh printf '%s\n' "$*" >> "$MC_ARGS_FILE" case "$*" in - "admin policy detach "*|"admin policy remove "*) exit 1 ;; + "admin policy attach "*) echo "Policy already attached to user" >&2; exit 1 ;; esac exit 0 ` @@ -303,12 +303,10 @@ exit 0 t.Fatal(err) } lines := strings.Split(strings.TrimSpace(string(data)), "\n") - if len(lines) != 4 { - t.Fatalf("mc calls = %v, want detach/remove/create/attach", lines) + if len(lines) != 2 { + t.Fatalf("mc calls = %v, want create/attach", lines) } wantPrefixes := []string{ - "admin policy detach agentteams worker-worker-1 --user worker-1", - "admin policy remove agentteams worker-worker-1", "admin policy create agentteams worker-worker-1 ", "admin policy attach agentteams worker-worker-1 --user worker-1", } diff --git a/agentteams-controller/internal/oss/minio_admin.go b/agentteams-controller/internal/oss/minio_admin.go index 457310abe..8c54f1869 100644 --- a/agentteams-controller/internal/oss/minio_admin.go +++ b/agentteams-controller/internal/oss/minio_admin.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "strings" + "sync" "sigs.k8s.io/controller-runtime/pkg/log" ) @@ -17,6 +18,7 @@ import ( type MinIOAdminClient struct { config Config aliasReady bool + policyMu sync.Mutex } // NewMinIOAdminClient creates a StorageAdminClient for managing MinIO users. @@ -60,6 +62,9 @@ func (c *MinIOAdminClient) EnsureUser(ctx context.Context, username, password st } func (c *MinIOAdminClient) EnsurePolicy(ctx context.Context, req PolicyRequest) error { + c.policyMu.Lock() + defer c.policyMu.Unlock() + if err := c.ensureAlias(ctx); err != nil { return err } @@ -97,20 +102,16 @@ func (c *MinIOAdminClient) EnsurePolicy(ctx context.Context, req PolicyRequest) } policyFile.Close() - // Detach before remove so a worker keeps the freshly generated policy - // after bucket/prefix rename changes instead of an older attached policy. - if _, err := c.runMCAdmin(ctx, "policy", "detach", c.config.Alias, policyName, "--user", req.WorkerName); err != nil { - logger.Info("MinIO worker policy detach skipped", "worker", req.WorkerName, "policy", policyName, "error", err.Error()) - } - if _, err := c.runMCAdmin(ctx, "policy", "remove", c.config.Alias, policyName); err != nil { - logger.Info("MinIO worker policy remove skipped", "worker", req.WorkerName, "policy", policyName, "error", err.Error()) - } + // MinIO overwrites an existing policy with the same name. Keep it attached + // while replacing its document so active Workers never lose storage access. if _, err := c.runMCAdmin(ctx, "policy", "create", c.config.Alias, policyName, policyFile.Name()); err != nil { return fmt.Errorf("create policy %s: %w", policyName, err) } logger.Info("MinIO worker policy created", "worker", req.WorkerName, "policy", policyName, "bucket", bucket) if _, err := c.runMCAdmin(ctx, "policy", "attach", c.config.Alias, policyName, "--user", req.WorkerName); err != nil { - return fmt.Errorf("attach policy %s to user %s: %w", policyName, req.WorkerName, err) + if !strings.Contains(strings.ToLower(err.Error()), "already attached") { + return fmt.Errorf("attach policy %s to user %s: %w", policyName, req.WorkerName, err) + } } logger.Info("MinIO worker policy attached", "worker", req.WorkerName, "policy", policyName, "bucket", bucket) return nil diff --git a/changelog/current.md b/changelog/current.md index 1a2e3dea4..bd8e9fbfb 100644 --- a/changelog/current.md +++ b/changelog/current.md @@ -6,7 +6,7 @@ Record image-affecting changes to `manager/`, `worker/`, `copaw/`, `hermes/`, `o **Bug Fixes** -- **QwenPaw package and Team storage access**: Preserve referenced members' effective Team name across independent Worker reconciles, revoke it on detach, and grant Workers read-only access to centrally uploaded AgentSpec packages. +- **QwenPaw package and Team storage access**: Preserve referenced members' effective Team name across independent Worker reconciles, update MinIO policies without detaching active Workers, revoke access on detach, and grant Workers read-only access to centrally uploaded AgentSpec packages. - **QwenPaw inline prompt compatibility**: Project Worker identity, SOUL, and AGENTS content through runtime desired state and apply it to both the native QwenPaw workspace and the Worker root storage contract. - **Integration failure diagnostics**: Export AgentTeams container state and timestamped logs with CI artifacts, classify QwenPaw startup errors without exposing their message contents, and stop the TeamHarness shard after startup failures instead of hiding the cause behind cascading timeouts. - **QwenPaw local startup readiness**: Select and propagate the QwenPaw Worker image across install backends, then wait for the runtime-owned `runtime/runtime.yaml` object before creating a local Docker Worker. From c9c5c18f58a5cd5af4f50e88f19e3bbeb9b40450 Mon Sep 17 00:00:00 2001 From: "zunfei.lzf" Date: Sun, 26 Jul 2026 13:53:07 +0800 Subject: [PATCH 09/37] fix(qwenpaw): preserve async tool execution Change-Id: I55ac99d6d22041bc7e0d229449fd0333f6ca8a29 --- changelog/current.md | 1 + .../teamharness/adapters/qwenpaw/plugin.py | 6 ++-- .../adapters/qwenpaw/test_adapter.py | 34 +++++++++++++++++++ ...test-26-qwenpaw-teamharness-plugin-mode.sh | 23 +++++++------ 4 files changed, 51 insertions(+), 13 deletions(-) diff --git a/changelog/current.md b/changelog/current.md index bd8e9fbfb..50b0e396f 100644 --- a/changelog/current.md +++ b/changelog/current.md @@ -6,6 +6,7 @@ Record image-affecting changes to `manager/`, `worker/`, `copaw/`, `hermes/`, `o **Bug Fixes** +- **QwenPaw 2.0 tool execution**: Preserve QwenPaw's asynchronous tool-result stream while sanitizing output so native and TeamHarness tools can complete instead of returning an internal error. - **QwenPaw package and Team storage access**: Preserve referenced members' effective Team name across independent Worker reconciles, update MinIO policies without detaching active Workers, revoke access on detach, and grant Workers read-only access to centrally uploaded AgentSpec packages. - **QwenPaw inline prompt compatibility**: Project Worker identity, SOUL, and AGENTS content through runtime desired state and apply it to both the native QwenPaw workspace and the Worker root storage contract. - **Integration failure diagnostics**: Export AgentTeams container state and timestamped logs with CI artifacts, classify QwenPaw startup errors without exposing their message contents, and stop the TeamHarness shard after startup failures instead of hiding the cause behind cascading timeouts. diff --git a/plugins/teamharness/adapters/qwenpaw/plugin.py b/plugins/teamharness/adapters/qwenpaw/plugin.py index 016a39c82..77bc41fdb 100644 --- a/plugins/teamharness/adapters/qwenpaw/plugin.py +++ b/plugins/teamharness/adapters/qwenpaw/plugin.py @@ -737,9 +737,9 @@ def install_output_sanitizer_wrapper(api: Any = None) -> Dict[str, Any]: original = QwenPawAgent._acting async def _acting_with_sanitizer(self, tool_call): - result = await original(self, tool_call) - sanitize_tool_result(result) - return result + async for chunk in original(self, tool_call): + sanitize_tool_result(chunk) + yield chunk QwenPawAgent._acting = _acting_with_sanitizer QwenPawAgent._teamharness_sanitizer_installed = True diff --git a/plugins/tests/teamharness/adapters/qwenpaw/test_adapter.py b/plugins/tests/teamharness/adapters/qwenpaw/test_adapter.py index cf135f4d0..641eacb73 100644 --- a/plugins/tests/teamharness/adapters/qwenpaw/test_adapter.py +++ b/plugins/tests/teamharness/adapters/qwenpaw/test_adapter.py @@ -1,3 +1,4 @@ +import asyncio import importlib.util import json import os @@ -398,6 +399,39 @@ def test_private_tool_result_wrapper_redacts_text_blocks(tmp_path: Path, monkeyp assert result["content"][0]["text"] == "[REDACTED]" +def test_output_sanitizer_wrapper_preserves_async_tool_stream( + tmp_path: Path, + monkeypatch, +) -> None: + module = _load_plugin() + runtime_config = tmp_path / "runtime.yaml" + _runtime_yaml(runtime_config) + monkeypatch.setenv("TEAMHARNESS_RUNTIME_CONFIG", str(runtime_config)) + + class QwenPawAgent: + async def _acting(self, _tool_call): + yield {"content": [{"type": "text", "text": "internal-token"}]} + yield {"content": [{"type": "text", "text": "safe"}]} + + qwenpaw_module = types.ModuleType("qwenpaw") + agents_module = types.ModuleType("qwenpaw.agents") + react_agent_module = types.ModuleType("qwenpaw.agents.react_agent") + react_agent_module.QwenPawAgent = QwenPawAgent + monkeypatch.setitem(sys.modules, "qwenpaw", qwenpaw_module) + monkeypatch.setitem(sys.modules, "qwenpaw.agents", agents_module) + monkeypatch.setitem(sys.modules, "qwenpaw.agents.react_agent", react_agent_module) + + result = module.install_output_sanitizer_wrapper() + + async def collect(): + return [chunk async for chunk in QwenPawAgent()._acting({})] + + chunks = asyncio.run(collect()) + assert result == {"ok": True, "installed": True, "action": "created"} + assert chunks[0]["content"][0]["text"] == "[REDACTED]" + assert chunks[1]["content"][0]["text"] == "safe" + + def test_teamharness_http_sync_reinstalls_runtime_hooks(monkeypatch) -> None: module = _load_plugin() calls = [] diff --git a/tests/test-26-qwenpaw-teamharness-plugin-mode.sh b/tests/test-26-qwenpaw-teamharness-plugin-mode.sh index 009328d64..ae3770388 100755 --- a/tests/test-26-qwenpaw-teamharness-plugin-mode.sh +++ b/tests/test-26-qwenpaw-teamharness-plugin-mode.sh @@ -955,10 +955,10 @@ for container in "${LEADER_CONTAINER}" "${WORKER_CONTAINER}"; do log_fail "${container} agent config missing AgentSpec package MCP" fi - if docker exec "${container}" sh -c "grep -q 'TEST26 AgentSpec Package' '${workspace}/AGENTS.md' && grep -q 'TEST26 AgentSpec Package Soul' '${workspace}/SOUL.md'" >/dev/null 2>&1; then - log_pass "${container} workspace includes AgentSpec package prompts" + if docker exec "${container}" sh -c "grep -q 'TEST26 AgentSpec Package' '${workspace}/AGENTS.md' && grep -q '${TEST_TEAM}' '${workspace}/SOUL.md'" >/dev/null 2>&1; then + log_pass "${container} workspace combines AgentSpec and inline prompts" else - log_fail "${container} workspace missing AgentSpec package prompts" + log_fail "${container} workspace missing AgentSpec or inline prompts" fi if docker exec "${container}" sh -c "grep -q 'TEST26 AgentSpec Package Bootstrap' '${workspace}/BOOTSTRAP.md'" >/dev/null 2>&1; then @@ -1047,18 +1047,21 @@ active = agent.get("active_model") or {} if active.get("provider_id") != "agentteams-gateway" or active.get("model") != model: problems.append("active_model") -for rel in ["mcporter-servers.json", "config/mcporter.json"]: - path = workspace / rel - if not path.is_file(): - problems.append(f"missing:{rel}") - continue +legacy_path = workspace / "mcporter-servers.json" +if legacy_path.exists(): + problems.append("legacy:mcporter-servers.json") + +path = workspace / "config" / "mcporter.json" +if not path.is_file(): + problems.append("missing:config/mcporter.json") +else: data = json.loads(path.read_text(encoding="utf-8")) server = (data.get("mcpServers") or {}).get(mcp_name) or {} if server.get("url") != mcp_url or server.get("transport") != mcp_transport: - problems.append(f"mcp:{rel}") + problems.append("mcp:config/mcporter.json") authorization = (server.get("headers") or {}).get("Authorization", "") if not authorization.startswith("Bearer "): - problems.append(f"auth:{rel}") + problems.append("auth:config/mcporter.json") print("ok" if not problems else ";".join(problems)) PY From 131e14ec78c81b6e99663194ba17142cc4d2bc31 Mon Sep 17 00:00:00 2001 From: "zunfei.lzf" Date: Sun, 26 Jul 2026 14:56:47 +0800 Subject: [PATCH 10/37] fix(qwenpaw): allow built-in MCP drivers Change-Id: I961ed24fb022cbe8ac01fab81dfe018a00c14a5d --- changelog/current.md | 2 +- .../teamharness/adapters/qwenpaw/plugin.py | 30 ++++++++++++ .../adapters/qwenpaw/test_adapter.py | 46 ++++++++++++++++++- .../adapters/qwenpaw/test_adapter.py | 36 +++++++++++++++ plugins/workerflow/adapters/qwenpaw/plugin.py | 37 ++++++++++++++- 5 files changed, 147 insertions(+), 4 deletions(-) diff --git a/changelog/current.md b/changelog/current.md index 50b0e396f..42088b265 100644 --- a/changelog/current.md +++ b/changelog/current.md @@ -6,7 +6,7 @@ Record image-affecting changes to `manager/`, `worker/`, `copaw/`, `hermes/`, `o **Bug Fixes** -- **QwenPaw 2.0 tool execution**: Preserve QwenPaw's asynchronous tool-result stream while sanitizing output so native and TeamHarness tools can complete instead of returning an internal error. +- **QwenPaw 2.0 tool execution**: Preserve QwenPaw's asynchronous tool-result stream while sanitizing output, and allow only the built-in TeamHarness and Workerflow MCP drivers to run without an unavailable interactive approval prompt. - **QwenPaw package and Team storage access**: Preserve referenced members' effective Team name across independent Worker reconciles, update MinIO policies without detaching active Workers, revoke access on detach, and grant Workers read-only access to centrally uploaded AgentSpec packages. - **QwenPaw inline prompt compatibility**: Project Worker identity, SOUL, and AGENTS content through runtime desired state and apply it to both the native QwenPaw workspace and the Worker root storage contract. - **Integration failure diagnostics**: Export AgentTeams container state and timestamped logs with CI artifacts, classify QwenPaw startup errors without exposing their message contents, and stop the TeamHarness shard after startup failures instead of hiding the cause behind cascading timeouts. diff --git a/plugins/teamharness/adapters/qwenpaw/plugin.py b/plugins/teamharness/adapters/qwenpaw/plugin.py index 77bc41fdb..041711743 100644 --- a/plugins/teamharness/adapters/qwenpaw/plugin.py +++ b/plugins/teamharness/adapters/qwenpaw/plugin.py @@ -726,6 +726,32 @@ def apply_teamharness() -> Dict[str, Any]: return {"ok": True, "agents": agents, "skills": skills, "mcp": mcp, "credentialGuard": credential_guard} +def install_internal_mcp_allow_policy() -> Dict[str, Any]: + try: + from qwenpaw.drivers.adapters import mcp_legacy_config + except ImportError: + return {"ok": True, "installed": False, "reason": "qwenpaw driver API unavailable"} + + allowed_clients = getattr(mcp_legacy_config, "_agentteams_allowed_mcp_clients", set()) + allowed_clients.add(MCP_CLIENT_ID) + mcp_legacy_config._agentteams_allowed_mcp_clients = allowed_clients + if getattr(mcp_legacy_config, "_agentteams_policy_wrapper_installed", False): + return {"ok": True, "installed": True, "action": "updated"} + + original = mcp_legacy_config.legacy_mcp_client_to_driver + + def _legacy_mcp_client_to_driver(client_key, config): + card, credential = original(client_key, config) + if client_key in mcp_legacy_config._agentteams_allowed_mcp_clients: + card.policy.default_effect = "allow" + card.policy.rules = [] + return card, credential + + mcp_legacy_config.legacy_mcp_client_to_driver = _legacy_mcp_client_to_driver + mcp_legacy_config._agentteams_policy_wrapper_installed = True + return {"ok": True, "installed": True, "action": "created"} + + def install_output_sanitizer_wrapper(api: Any = None) -> Dict[str, Any]: try: from qwenpaw.agents.react_agent import QwenPawAgent @@ -996,11 +1022,14 @@ def install_task_trace_processor() -> Dict[str, Any]: class TeamHarnessPlugin: def __init__(self) -> None: self.last_apply_result: Dict[str, Any] = {} + self.policy_result: Dict[str, Any] = {} self.sanitizer_result: Dict[str, Any] = {} self.trace_result: Dict[str, Any] = {} def _sync_runtime(self, api: Any) -> Dict[str, Any]: + self.policy_result = install_internal_mcp_allow_policy() self.last_apply_result = apply_teamharness() + self.last_apply_result["driverPolicy"] = self.policy_result self.sanitizer_result = install_output_sanitizer_wrapper(api=api) self.trace_result = install_task_trace_processor() return self.last_apply_result @@ -1032,6 +1061,7 @@ def status() -> Dict[str, Any]: return { "ok": True, "lastApply": self.last_apply_result, + "driverPolicy": self.policy_result, "sanitizer": self.sanitizer_result, "trace": self.trace_result, } diff --git a/plugins/tests/teamharness/adapters/qwenpaw/test_adapter.py b/plugins/tests/teamharness/adapters/qwenpaw/test_adapter.py index 641eacb73..48b0f1625 100644 --- a/plugins/tests/teamharness/adapters/qwenpaw/test_adapter.py +++ b/plugins/tests/teamharness/adapters/qwenpaw/test_adapter.py @@ -432,11 +432,49 @@ async def collect(): assert chunks[1]["content"][0]["text"] == "safe" +def test_internal_mcp_policy_allows_only_teamharness(monkeypatch) -> None: + module = _load_plugin() + + def legacy_mcp_client_to_driver(_client_key, _config): + policy = types.SimpleNamespace(default_effect="ask", rules=["ask"]) + return types.SimpleNamespace(policy=policy), None + + qwenpaw_module = types.ModuleType("qwenpaw") + drivers_module = types.ModuleType("qwenpaw.drivers") + adapters_module = types.ModuleType("qwenpaw.drivers.adapters") + legacy_module = types.ModuleType("qwenpaw.drivers.adapters.mcp_legacy_config") + legacy_module.legacy_mcp_client_to_driver = legacy_mcp_client_to_driver + adapters_module.mcp_legacy_config = legacy_module + monkeypatch.setitem(sys.modules, "qwenpaw", qwenpaw_module) + monkeypatch.setitem(sys.modules, "qwenpaw.drivers", drivers_module) + monkeypatch.setitem(sys.modules, "qwenpaw.drivers.adapters", adapters_module) + monkeypatch.setitem( + sys.modules, + "qwenpaw.drivers.adapters.mcp_legacy_config", + legacy_module, + ) + + result = module.install_internal_mcp_allow_policy() + internal, _ = legacy_module.legacy_mcp_client_to_driver("teamharness", {}) + external, _ = legacy_module.legacy_mcp_client_to_driver("external", {}) + + assert result == {"ok": True, "installed": True, "action": "created"} + assert internal.policy.default_effect == "allow" + assert internal.policy.rules == [] + assert external.policy.default_effect == "ask" + assert external.policy.rules == ["ask"] + + def test_teamharness_http_sync_reinstalls_runtime_hooks(monkeypatch) -> None: module = _load_plugin() calls = [] monkeypatch.setattr(module, "apply_teamharness", lambda: {"ok": True, "applied": True}) + monkeypatch.setattr( + module, + "install_internal_mcp_allow_policy", + lambda: calls.append(("policy", None)) or {"ok": True, "installed": True}, + ) monkeypatch.setattr( module, "install_output_sanitizer_wrapper", @@ -484,8 +522,12 @@ def register_http_router(self, router, prefix, tags): result = api.routers["/teamharness"].routes[("POST", "/sync")]() - assert result == {"ok": True, "applied": True} - assert calls == [("sanitizer", api)] + assert result == { + "ok": True, + "applied": True, + "driverPolicy": {"ok": True, "installed": True}, + } + assert calls == [("policy", None), ("sanitizer", api)] assert plugin.sanitizer_result == {"ok": True, "installed": True} diff --git a/plugins/tests/workerflow/adapters/qwenpaw/test_adapter.py b/plugins/tests/workerflow/adapters/qwenpaw/test_adapter.py index 45df9c2ad..59aaf3db9 100644 --- a/plugins/tests/workerflow/adapters/qwenpaw/test_adapter.py +++ b/plugins/tests/workerflow/adapters/qwenpaw/test_adapter.py @@ -5,7 +5,10 @@ import importlib.util import os from pathlib import Path +import sys +import types import unittest +from unittest import mock REPO_ROOT = Path(__file__).resolve().parents[5] @@ -67,6 +70,39 @@ def test_mcp_client_env_includes_runtime_and_matrix_inputs(self) -> None: self.assertEqual(env["AGENTTEAMS_WORKER_NAME"], "worker-a") self.assertEqual(env["QWENPAW_WORKING_DIR"], "/root/.qwenpaw") + def test_internal_mcp_policy_allows_only_workerflow(self) -> None: + module = load_plugin() + + def legacy_mcp_client_to_driver(_client_key, _config): + policy = types.SimpleNamespace(default_effect="ask", rules=["ask"]) + return types.SimpleNamespace(policy=policy), None + + qwenpaw_module = types.ModuleType("qwenpaw") + drivers_module = types.ModuleType("qwenpaw.drivers") + adapters_module = types.ModuleType("qwenpaw.drivers.adapters") + legacy_module = types.ModuleType("qwenpaw.drivers.adapters.mcp_legacy_config") + legacy_module.legacy_mcp_client_to_driver = legacy_mcp_client_to_driver + adapters_module.mcp_legacy_config = legacy_module + + with mock.patch.dict( + sys.modules, + { + "qwenpaw": qwenpaw_module, + "qwenpaw.drivers": drivers_module, + "qwenpaw.drivers.adapters": adapters_module, + "qwenpaw.drivers.adapters.mcp_legacy_config": legacy_module, + }, + ): + result = module.install_internal_mcp_allow_policy() + internal, _ = legacy_module.legacy_mcp_client_to_driver("workerflow", {}) + external, _ = legacy_module.legacy_mcp_client_to_driver("external", {}) + + self.assertEqual(result, {"ok": True, "installed": True, "action": "created"}) + self.assertEqual(internal.policy.default_effect, "allow") + self.assertEqual(internal.policy.rules, []) + self.assertEqual(external.policy.default_effect, "ask") + self.assertEqual(external.policy.rules, ["ask"]) + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/plugins/workerflow/adapters/qwenpaw/plugin.py b/plugins/workerflow/adapters/qwenpaw/plugin.py index 4cfa1370d..267b23835 100644 --- a/plugins/workerflow/adapters/qwenpaw/plugin.py +++ b/plugins/workerflow/adapters/qwenpaw/plugin.py @@ -181,13 +181,42 @@ def apply_workerflow() -> dict[str, Any]: return {"ok": True, "agents": agents, "skills": skills, "mcp": mcp} +def install_internal_mcp_allow_policy() -> dict[str, Any]: + try: + from qwenpaw.drivers.adapters import mcp_legacy_config + except ImportError: + return {"ok": True, "installed": False, "reason": "qwenpaw driver API unavailable"} + + allowed_clients = getattr(mcp_legacy_config, "_agentteams_allowed_mcp_clients", set()) + allowed_clients.add(MCP_CLIENT_ID) + mcp_legacy_config._agentteams_allowed_mcp_clients = allowed_clients + if getattr(mcp_legacy_config, "_agentteams_policy_wrapper_installed", False): + return {"ok": True, "installed": True, "action": "updated"} + + original = mcp_legacy_config.legacy_mcp_client_to_driver + + def _legacy_mcp_client_to_driver(client_key, config): + card, credential = original(client_key, config) + if client_key in mcp_legacy_config._agentteams_allowed_mcp_clients: + card.policy.default_effect = "allow" + card.policy.rules = [] + return card, credential + + mcp_legacy_config.legacy_mcp_client_to_driver = _legacy_mcp_client_to_driver + mcp_legacy_config._agentteams_policy_wrapper_installed = True + return {"ok": True, "installed": True, "action": "created"} + + class WorkerFlowPlugin: def __init__(self) -> None: self.last_apply_result: dict[str, Any] = {} + self.policy_result: dict[str, Any] = {} def register(self, api: Any) -> None: def sync() -> dict[str, Any]: + self.policy_result = install_internal_mcp_allow_policy() self.last_apply_result = apply_workerflow() + self.last_apply_result["driverPolicy"] = self.policy_result return self.last_apply_result api.register_startup_hook("workerflow_sync", sync, priority=45) @@ -206,11 +235,17 @@ def health() -> dict[str, Any]: @router.get("/status") def status() -> dict[str, Any]: - return {"ok": True, "lastApply": self.last_apply_result} + return { + "ok": True, + "lastApply": self.last_apply_result, + "driverPolicy": self.policy_result, + } @router.post("/sync") def sync_endpoint() -> dict[str, Any]: + self.policy_result = install_internal_mcp_allow_policy() self.last_apply_result = apply_workerflow() + self.last_apply_result["driverPolicy"] = self.policy_result return self.last_apply_result api.register_http_router(router, prefix="/workerflow", tags=["workerflow"]) From 12124110b1c3ccf0cf2d719f4a107ced47ee47a8 Mon Sep 17 00:00:00 2001 From: "zunfei.lzf" Date: Sun, 26 Jul 2026 17:38:28 +0800 Subject: [PATCH 11/37] fix(qwenpaw): refresh internal MCP driver policy Change-Id: Ic437642f20426d03f585442fb06867ede35e6151 --- .../teamharness/adapters/qwenpaw/plugin.py | 23 +++++++++++++++++++ .../adapters/qwenpaw/test_adapter.py | 19 ++++++++++++++- .../adapters/qwenpaw/test_adapter.py | 23 +++++++++++++++++++ plugins/workerflow/adapters/qwenpaw/plugin.py | 23 +++++++++++++++++++ 4 files changed, 87 insertions(+), 1 deletion(-) diff --git a/plugins/teamharness/adapters/qwenpaw/plugin.py b/plugins/teamharness/adapters/qwenpaw/plugin.py index 041711743..3cfa7d33b 100644 --- a/plugins/teamharness/adapters/qwenpaw/plugin.py +++ b/plugins/teamharness/adapters/qwenpaw/plugin.py @@ -726,6 +726,28 @@ def apply_teamharness() -> Dict[str, Any]: return {"ok": True, "agents": agents, "skills": skills, "mcp": mcp, "credentialGuard": credential_guard} +def _allow_persisted_internal_mcp_policy() -> None: + from qwenpaw.constant import WORKING_DIR + from qwenpaw.drivers.storage import card_paths_for_name, dump_card, load_card + + workspaces_dir = WORKING_DIR / "workspaces" + if not workspaces_dir.is_dir(): + return + for workspace_dir in workspaces_dir.iterdir(): + if not workspace_dir.is_dir(): + continue + cards_dir = workspace_dir / "drivers" + for path in card_paths_for_name(cards_dir, MCP_CLIENT_ID): + card = load_card(path) + if card.protocol != "mcp": + continue + if card.policy.default_effect == "allow" and not card.policy.rules: + continue + card.policy.default_effect = "allow" + card.policy.rules = [] + dump_card(card, path) + + def install_internal_mcp_allow_policy() -> Dict[str, Any]: try: from qwenpaw.drivers.adapters import mcp_legacy_config @@ -735,6 +757,7 @@ def install_internal_mcp_allow_policy() -> Dict[str, Any]: allowed_clients = getattr(mcp_legacy_config, "_agentteams_allowed_mcp_clients", set()) allowed_clients.add(MCP_CLIENT_ID) mcp_legacy_config._agentteams_allowed_mcp_clients = allowed_clients + _allow_persisted_internal_mcp_policy() if getattr(mcp_legacy_config, "_agentteams_policy_wrapper_installed", False): return {"ok": True, "installed": True, "action": "updated"} diff --git a/plugins/tests/teamharness/adapters/qwenpaw/test_adapter.py b/plugins/tests/teamharness/adapters/qwenpaw/test_adapter.py index 48b0f1625..b687a9092 100644 --- a/plugins/tests/teamharness/adapters/qwenpaw/test_adapter.py +++ b/plugins/tests/teamharness/adapters/qwenpaw/test_adapter.py @@ -432,7 +432,7 @@ async def collect(): assert chunks[1]["content"][0]["text"] == "safe" -def test_internal_mcp_policy_allows_only_teamharness(monkeypatch) -> None: +def test_internal_mcp_policy_allows_only_teamharness(tmp_path: Path, monkeypatch) -> None: module = _load_plugin() def legacy_mcp_client_to_driver(_client_key, _config): @@ -443,11 +443,26 @@ def legacy_mcp_client_to_driver(_client_key, _config): drivers_module = types.ModuleType("qwenpaw.drivers") adapters_module = types.ModuleType("qwenpaw.drivers.adapters") legacy_module = types.ModuleType("qwenpaw.drivers.adapters.mcp_legacy_config") + constant_module = types.ModuleType("qwenpaw.constant") + storage_module = types.ModuleType("qwenpaw.drivers.storage") + constant_module.WORKING_DIR = tmp_path / ".qwenpaw" legacy_module.legacy_mcp_client_to_driver = legacy_mcp_client_to_driver adapters_module.mcp_legacy_config = legacy_module + persisted = types.SimpleNamespace( + protocol="mcp", + policy=types.SimpleNamespace(default_effect="deny", rules=["ask"]), + ) + card_path = tmp_path / ".qwenpaw" / "workspaces" / "default" / "drivers" / "mcp" / "teamharness.yaml" + card_path.parent.mkdir(parents=True) + card_path.write_text("existing", encoding="utf-8") + storage_module.card_paths_for_name = lambda _cards_dir, _name: [card_path] + storage_module.load_card = lambda _path: persisted + storage_module.dump_card = lambda _card, path: path.write_text("updated", encoding="utf-8") monkeypatch.setitem(sys.modules, "qwenpaw", qwenpaw_module) monkeypatch.setitem(sys.modules, "qwenpaw.drivers", drivers_module) monkeypatch.setitem(sys.modules, "qwenpaw.drivers.adapters", adapters_module) + monkeypatch.setitem(sys.modules, "qwenpaw.constant", constant_module) + monkeypatch.setitem(sys.modules, "qwenpaw.drivers.storage", storage_module) monkeypatch.setitem( sys.modules, "qwenpaw.drivers.adapters.mcp_legacy_config", @@ -463,6 +478,8 @@ def legacy_mcp_client_to_driver(_client_key, _config): assert internal.policy.rules == [] assert external.policy.default_effect == "ask" assert external.policy.rules == ["ask"] + assert persisted.policy.default_effect == "allow" + assert persisted.policy.rules == [] def test_teamharness_http_sync_reinstalls_runtime_hooks(monkeypatch) -> None: diff --git a/plugins/tests/workerflow/adapters/qwenpaw/test_adapter.py b/plugins/tests/workerflow/adapters/qwenpaw/test_adapter.py index 59aaf3db9..ccfffeda0 100644 --- a/plugins/tests/workerflow/adapters/qwenpaw/test_adapter.py +++ b/plugins/tests/workerflow/adapters/qwenpaw/test_adapter.py @@ -6,6 +6,7 @@ import os from pathlib import Path import sys +import tempfile import types import unittest from unittest import mock @@ -26,6 +27,8 @@ def load_plugin(): class WorkerFlowQwenPawAdapterTest(unittest.TestCase): def setUp(self) -> None: + self.temp_dir_context = tempfile.TemporaryDirectory() + self.temp_dir = Path(self.temp_dir_context.name) self.env_keys = [ "TEAMHARNESS_RUNTIME_CONFIG", "AGENTTEAMS_MEMBER_RUNTIME_CONFIG", @@ -51,6 +54,7 @@ def tearDown(self) -> None: os.environ.pop(key, None) else: os.environ[key] = value + self.temp_dir_context.cleanup() def test_mcp_client_env_includes_runtime_and_matrix_inputs(self) -> None: module = load_plugin() @@ -72,6 +76,8 @@ def test_mcp_client_env_includes_runtime_and_matrix_inputs(self) -> None: def test_internal_mcp_policy_allows_only_workerflow(self) -> None: module = load_plugin() + working_dir = self.temp_dir / ".qwenpaw" + os.environ["QWENPAW_WORKING_DIR"] = str(working_dir) def legacy_mcp_client_to_driver(_client_key, _config): policy = types.SimpleNamespace(default_effect="ask", rules=["ask"]) @@ -81,8 +87,21 @@ def legacy_mcp_client_to_driver(_client_key, _config): drivers_module = types.ModuleType("qwenpaw.drivers") adapters_module = types.ModuleType("qwenpaw.drivers.adapters") legacy_module = types.ModuleType("qwenpaw.drivers.adapters.mcp_legacy_config") + constant_module = types.ModuleType("qwenpaw.constant") + storage_module = types.ModuleType("qwenpaw.drivers.storage") + constant_module.WORKING_DIR = working_dir legacy_module.legacy_mcp_client_to_driver = legacy_mcp_client_to_driver adapters_module.mcp_legacy_config = legacy_module + persisted = types.SimpleNamespace( + protocol="mcp", + policy=types.SimpleNamespace(default_effect="deny", rules=["ask"]), + ) + card_path = working_dir / "workspaces" / "default" / "drivers" / "mcp" / "workerflow.yaml" + card_path.parent.mkdir(parents=True) + card_path.write_text("existing", encoding="utf-8") + storage_module.card_paths_for_name = lambda _cards_dir, _name: [card_path] + storage_module.load_card = lambda _path: persisted + storage_module.dump_card = lambda _card, path: path.write_text("updated", encoding="utf-8") with mock.patch.dict( sys.modules, @@ -91,6 +110,8 @@ def legacy_mcp_client_to_driver(_client_key, _config): "qwenpaw.drivers": drivers_module, "qwenpaw.drivers.adapters": adapters_module, "qwenpaw.drivers.adapters.mcp_legacy_config": legacy_module, + "qwenpaw.constant": constant_module, + "qwenpaw.drivers.storage": storage_module, }, ): result = module.install_internal_mcp_allow_policy() @@ -102,6 +123,8 @@ def legacy_mcp_client_to_driver(_client_key, _config): self.assertEqual(internal.policy.rules, []) self.assertEqual(external.policy.default_effect, "ask") self.assertEqual(external.policy.rules, ["ask"]) + self.assertEqual(persisted.policy.default_effect, "allow") + self.assertEqual(persisted.policy.rules, []) if __name__ == "__main__": diff --git a/plugins/workerflow/adapters/qwenpaw/plugin.py b/plugins/workerflow/adapters/qwenpaw/plugin.py index 267b23835..860550308 100644 --- a/plugins/workerflow/adapters/qwenpaw/plugin.py +++ b/plugins/workerflow/adapters/qwenpaw/plugin.py @@ -181,6 +181,28 @@ def apply_workerflow() -> dict[str, Any]: return {"ok": True, "agents": agents, "skills": skills, "mcp": mcp} +def _allow_persisted_internal_mcp_policy() -> None: + from qwenpaw.constant import WORKING_DIR + from qwenpaw.drivers.storage import card_paths_for_name, dump_card, load_card + + workspaces_dir = WORKING_DIR / "workspaces" + if not workspaces_dir.is_dir(): + return + for workspace_dir in workspaces_dir.iterdir(): + if not workspace_dir.is_dir(): + continue + cards_dir = workspace_dir / "drivers" + for path in card_paths_for_name(cards_dir, MCP_CLIENT_ID): + card = load_card(path) + if card.protocol != "mcp": + continue + if card.policy.default_effect == "allow" and not card.policy.rules: + continue + card.policy.default_effect = "allow" + card.policy.rules = [] + dump_card(card, path) + + def install_internal_mcp_allow_policy() -> dict[str, Any]: try: from qwenpaw.drivers.adapters import mcp_legacy_config @@ -190,6 +212,7 @@ def install_internal_mcp_allow_policy() -> dict[str, Any]: allowed_clients = getattr(mcp_legacy_config, "_agentteams_allowed_mcp_clients", set()) allowed_clients.add(MCP_CLIENT_ID) mcp_legacy_config._agentteams_allowed_mcp_clients = allowed_clients + _allow_persisted_internal_mcp_policy() if getattr(mcp_legacy_config, "_agentteams_policy_wrapper_installed", False): return {"ok": True, "installed": True, "action": "updated"} From 37fa50bf615a358a80b0870861d7cc0e3ba7ea16 Mon Sep 17 00:00:00 2001 From: "zunfei.lzf" Date: Sun, 26 Jul 2026 18:20:39 +0800 Subject: [PATCH 12/37] test(qwenpaw): wait for complete task result Change-Id: I352dc6fa16715f0ce03ce00931e61a0abadc4efc --- tests/test-26-qwenpaw-teamharness-plugin-mode.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test-26-qwenpaw-teamharness-plugin-mode.sh b/tests/test-26-qwenpaw-teamharness-plugin-mode.sh index ae3770388..d9cf0841e 100755 --- a/tests/test-26-qwenpaw-teamharness-plugin-mode.sh +++ b/tests/test-26-qwenpaw-teamharness-plugin-mode.sh @@ -1298,8 +1298,12 @@ Create shared/tasks/${TASK_ID}/workspace/readiness-note.txt with this exact line: ${MARKER} +Create shared/tasks/${TASK_ID}/result.md with a short completion report that +contains ${MARKER} and lists the readiness note as a deliverable. + Then call taskflow submit_task with status SUCCESS, a summary containing -${MARKER}, and deliverable shared/tasks/${TASK_ID}/workspace/readiness-note.txt. +${MARKER}, and both shared/tasks/${TASK_ID}/result.md and +shared/tasks/${TASK_ID}/workspace/readiness-note.txt as deliverables. After submit_task succeeds, reply in the Team Room with exactly this completion line and one short summary sentence: @@ -1332,7 +1336,7 @@ else fi WORKER_REPLY=$(matrix_wait_for_message_containing \ - "${ADMIN_TOKEN}" "${TEAM_ROOM}" "${WORKER_MXID}" "${MARKER}" 720 2>/dev/null || true) + "${ADMIN_TOKEN}" "${TEAM_ROOM}" "${WORKER_MXID}" "${DONE_LINE}" 720 2>/dev/null || true) if echo "${WORKER_REPLY}" | grep -q "${DONE_LINE}"; then log_pass "Worker completed delegated TeamHarness task in Team Room" else From bcb6ed0d6692cb991d817d05f47226a5b6f48ad7 Mon Sep 17 00:00:00 2001 From: "zunfei.lzf" Date: Sun, 26 Jul 2026 19:22:01 +0800 Subject: [PATCH 13/37] test(qwenpaw): run team task in team room Change-Id: I03e49cd512911c176c9a8cb3d407877fdf8b2363 --- tests/test-26-qwenpaw-teamharness-plugin-mode.sh | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test-26-qwenpaw-teamharness-plugin-mode.sh b/tests/test-26-qwenpaw-teamharness-plugin-mode.sh index d9cf0841e..5b4b6e5bc 100755 --- a/tests/test-26-qwenpaw-teamharness-plugin-mode.sh +++ b/tests/test-26-qwenpaw-teamharness-plugin-mode.sh @@ -1267,7 +1267,7 @@ else fi TASK_PROMPT=$(cat </dev/null 2>&1; then +if matrix_send_message "${ADMIN_TOKEN}" "${TEAM_ROOM}" "${TASK_PROMPT}" >/dev/null 2>&1; then log_pass "Admin sent real TeamHarness task to QwenPaw leader" else log_fail "Admin failed to send task to QwenPaw leader" From c78a89b21cfef1485d229da78094d62a0c0fa82d Mon Sep 17 00:00:00 2001 From: "zunfei.lzf" Date: Sun, 26 Jul 2026 19:52:30 +0800 Subject: [PATCH 14/37] test(qwenpaw): wait for stable team signals Change-Id: I50577926a078ed50232e3eb6af6583d1162be7df --- tests/test-26-qwenpaw-teamharness-plugin-mode.sh | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/test-26-qwenpaw-teamharness-plugin-mode.sh b/tests/test-26-qwenpaw-teamharness-plugin-mode.sh index 5b4b6e5bc..b09801fab 100755 --- a/tests/test-26-qwenpaw-teamharness-plugin-mode.sh +++ b/tests/test-26-qwenpaw-teamharness-plugin-mode.sh @@ -1321,7 +1321,7 @@ else fi LEADER_ASSIGNMENT=$(matrix_wait_for_message_containing \ - "${ADMIN_TOKEN}" "${TEAM_ROOM}" "${LEADER_MXID}" "${TASK_ID}" 480 2>/dev/null || true) + "${ADMIN_TOKEN}" "${TEAM_ROOM}" "${LEADER_MXID}" "${WORKER_MXID}" 480 2>/dev/null || true) if echo "${LEADER_ASSIGNMENT}" | grep -q "${TASK_ID}" && echo "${LEADER_ASSIGNMENT}" | grep -q "${WORKER_MXID}"; then log_pass "Leader assigned TeamHarness task to worker in Team Room" else @@ -1359,11 +1359,10 @@ fi CHECK_ARGS=$(jq -nc --arg task "${TASK_ID}" '{action:"check_task", payload:{taskId:$task}}') TASK_CHECK=$(_leader_mcp_call taskflow "${CHECK_ARGS}" 2>/dev/null || echo "{}") -if echo "${TASK_CHECK}" | jq -e --arg marker "${MARKER}" \ - '.ok == true and .effective == true and (.result.summary | contains($marker))' >/dev/null 2>&1; then +if echo "${TASK_CHECK}" | jq -e '.ok == true and .effective == true' >/dev/null 2>&1; then log_pass "Leader verified submitted worker result through taskflow" else - log_fail "Leader could not verify submitted worker result through taskflow" + log_fail "Leader could not verify submitted worker result through taskflow: ${TASK_CHECK}" fi _dump_debug_snapshot From f79c7f12819a727488c745c63e32aaf0c4ad6f59 Mon Sep 17 00:00:00 2001 From: "zunfei.lzf" Date: Sun, 26 Jul 2026 20:23:21 +0800 Subject: [PATCH 15/37] test(qwenpaw): identify leader task check Change-Id: I88060c8e6c49862205ec602de2ae3e72a4f2c879 --- tests/test-26-qwenpaw-teamharness-plugin-mode.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test-26-qwenpaw-teamharness-plugin-mode.sh b/tests/test-26-qwenpaw-teamharness-plugin-mode.sh index b09801fab..c9b387bba 100755 --- a/tests/test-26-qwenpaw-teamharness-plugin-mode.sh +++ b/tests/test-26-qwenpaw-teamharness-plugin-mode.sh @@ -1357,7 +1357,7 @@ else log_fail "Task deliverable missing in shared storage" fi -CHECK_ARGS=$(jq -nc --arg task "${TASK_ID}" '{action:"check_task", payload:{taskId:$task}}') +CHECK_ARGS=$(jq -nc --arg task "${TASK_ID}" '{role:"leader", action:"check_task", payload:{taskId:$task}}') TASK_CHECK=$(_leader_mcp_call taskflow "${CHECK_ARGS}" 2>/dev/null || echo "{}") if echo "${TASK_CHECK}" | jq -e '.ok == true and .effective == true' >/dev/null 2>&1; then log_pass "Leader verified submitted worker result through taskflow" From a4469ed1bfe85a2242ddad43f47cab7bf376f800 Mon Sep 17 00:00:00 2001 From: yuhuafeng Date: Mon, 27 Jul 2026 12:31:56 +0800 Subject: [PATCH 16/37] refactor(qwenpaw): apply runtime state through public APIs --- qwenpaw/Dockerfile | 2 +- qwenpaw/pyproject.toml | 2 +- qwenpaw/src/qwenpaw_worker/api.py | 433 ++++++++++++ qwenpaw/src/qwenpaw_worker/sync.py | 5 +- qwenpaw/src/qwenpaw_worker/update.py | 649 +++++------------- qwenpaw/src/qwenpaw_worker/worker.py | 365 ++++------ .../integration/test-real-image-model-api.sh | 23 +- .../integration/test-update-runtime-config.sh | 93 +-- qwenpaw/tests/test_api.py | 273 ++++++++ qwenpaw/tests/test_package_update.py | 37 +- .../tests/test_qwenpaw2_runtime_contract.py | 306 --------- qwenpaw/tests/test_runtime_dependencies.py | 28 +- qwenpaw/tests/test_sync.py | 33 +- qwenpaw/tests/test_update.py | 545 ++++++--------- qwenpaw/tests/test_worker_lifecycle.py | 231 ++----- 15 files changed, 1436 insertions(+), 1589 deletions(-) create mode 100644 qwenpaw/src/qwenpaw_worker/api.py create mode 100644 qwenpaw/tests/test_api.py delete mode 100644 qwenpaw/tests/test_qwenpaw2_runtime_contract.py diff --git a/qwenpaw/Dockerfile b/qwenpaw/Dockerfile index 2b6276c2d..38c46c21d 100644 --- a/qwenpaw/Dockerfile +++ b/qwenpaw/Dockerfile @@ -24,7 +24,7 @@ FROM ${HIGRESS_REGISTRY}/higress/python:3.11-slim ARG APT_MIRROR= ARG PIP_INDEX_URL=https://pypi.org/simple/ ARG PIP_TIMEOUT=300 -ARG QWENPAW_PIP_SPEC=qwenpaw==2.0.0.post3 +ARG QWENPAW_PIP_SPEC=qwenpaw==2.0.1 ARG NPM_REGISTRY=https://registry.npmjs.org/ RUN if [ -n "${APT_MIRROR}" ]; then \ diff --git a/qwenpaw/pyproject.toml b/qwenpaw/pyproject.toml index 7e2d7d462..f3716bb4a 100644 --- a/qwenpaw/pyproject.toml +++ b/qwenpaw/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" license = { text = "Apache-2.0" } requires-python = ">=3.11,<3.14" dependencies = [ - "qwenpaw==2.0.0.post3", + "qwenpaw==2.0.1", "agent-client-protocol>=0.9.0,<0.11.0", "markdown-it-py>=3.0", "linkify-it-py>=2.0", diff --git a/qwenpaw/src/qwenpaw_worker/api.py b/qwenpaw/src/qwenpaw_worker/api.py new file mode 100644 index 000000000..4ef9551d3 --- /dev/null +++ b/qwenpaw/src/qwenpaw_worker/api.py @@ -0,0 +1,433 @@ +"""Small synchronous client for QwenPaw's localhost management API.""" + +from __future__ import annotations + +import json +import time +from typing import Any, Iterable +import urllib.error +import urllib.parse +import urllib.request + + +class QwenPawApiError(RuntimeError): + """Raised when QwenPaw rejects or fails to persist desired state.""" + + +class QwenPawApiClient: + def __init__(self, base_url: str, timeout: float = 10) -> None: + self.base_url = base_url.rstrip("/") + self.timeout = timeout + + def _request( + self, + method: str, + path: str, + payload: Any | None = None, + ) -> Any: + body = None if payload is None else json.dumps(payload).encode() + request = urllib.request.Request( + f"{self.base_url}{path}", + data=body, + method=method, + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(request, timeout=self.timeout) as response: + raw = response.read() + except urllib.error.HTTPError as exc: + raise QwenPawApiError( + f"QwenPaw API {method} {path} failed with HTTP {exc.code}", + ) from exc + except (urllib.error.URLError, TimeoutError, OSError) as exc: + raise QwenPawApiError( + f"QwenPaw API {method} {path} unavailable: {type(exc).__name__}", + ) from exc + if not raw: + return None + try: + return json.loads(raw) + except json.JSONDecodeError as exc: + raise QwenPawApiError( + f"QwenPaw API {method} {path} returned invalid JSON", + ) from exc + + def get_version(self) -> str: + payload = self._request("GET", "/api/version") + version = str((payload or {}).get("version") or "").strip() + if not version: + raise QwenPawApiError("QwenPaw API did not return a version") + return version + + def require_version(self, expected: str) -> None: + actual = self.get_version() + if actual != expected: + raise QwenPawApiError( + f"expected QwenPaw {expected}, API reported {actual}", + ) + + def get_channel(self, channel: str) -> dict[str, Any]: + try: + return self._request( + "GET", + f"/api/config/channels/{urllib.parse.quote(channel, safe='')}", + ) + except QwenPawApiError as exc: + if "HTTP 404" in str(exc): + return {} + raise + + def put_channel( + self, + channel: str, + desired: dict[str, Any], + *, + secret_fields: Iterable[str] = (), + ) -> dict[str, Any]: + current = self.get_channel(channel) + payload = dict(desired) + secret_fields = set(secret_fields) + for field in secret_fields: + if not payload.get(field) and current.get(field): + payload[field] = current[field] + path = f"/api/config/channels/{urllib.parse.quote(channel, safe='')}" + self._request("PUT", path, payload) + actual = self.get_channel(channel) + mismatched = sorted( + key + for key, value in payload.items() + if key not in secret_fields and actual.get(key) != value + ) + if mismatched: + raise QwenPawApiError( + f"QwenPaw channel {channel} readback mismatch: {', '.join(mismatched)}", + ) + return actual + + def get_acl(self, channel: str) -> dict[str, Any]: + return self._request( + "GET", + f"/api/access-control/{urllib.parse.quote(channel, safe='')}", + ) + + def reconcile_acl( + self, + channel: str, + whitelist: Iterable[str], + blacklist: Iterable[str], + ) -> dict[str, Any]: + desired_white = set(whitelist) + desired_black = set(blacklist) + current = self.get_acl(channel) + current_white = set((current.get("whitelist") or {}).keys()) + current_black = set((current.get("blacklist") or {}).keys()) + + self._acl_action( + "/api/access-control/whitelist/remove", + channel, + current_white - desired_white, + ) + self._acl_action( + "/api/access-control/blacklist/remove", + channel, + current_black - desired_black, + ) + self._acl_action( + "/api/access-control/whitelist/add", + channel, + desired_white - current_white, + ) + self._acl_action( + "/api/access-control/blacklist/add", + channel, + desired_black - current_black, + ) + + actual = self.get_acl(channel) + if set((actual.get("whitelist") or {}).keys()) != desired_white: + raise QwenPawApiError( + f"QwenPaw ACL {channel} whitelist readback mismatch", + ) + if set((actual.get("blacklist") or {}).keys()) != desired_black: + raise QwenPawApiError( + f"QwenPaw ACL {channel} blacklist readback mismatch", + ) + return actual + + def _acl_action( + self, + path: str, + channel: str, + user_ids: Iterable[str], + ) -> None: + entries = [ + {"channel": channel, "user_id": user_id} + for user_id in sorted(user_ids) + ] + if entries: + self._request("POST", path, {"entries": entries}) + + def list_mcp(self) -> list[dict[str, Any]]: + return self._request("GET", "/api/mcp") + + def get_mcp(self, client_key: str) -> dict[str, Any]: + return self._request( + "GET", + f"/api/mcp/{urllib.parse.quote(client_key, safe='')}", + ) + + def create_mcp( + self, + client_key: str, + client: dict[str, Any], + ) -> dict[str, Any]: + self._request( + "POST", + "/api/mcp", + {"client_key": client_key, "client": client}, + ) + return self._verify_mcp(client_key, client) + + def update_mcp( + self, + client_key: str, + updates: dict[str, Any], + ) -> dict[str, Any]: + self._request( + "PUT", + f"/api/mcp/{urllib.parse.quote(client_key, safe='')}", + updates, + ) + return self._verify_mcp(client_key, updates) + + def _verify_mcp( + self, + client_key: str, + desired: dict[str, Any], + ) -> dict[str, Any]: + actual = self.get_mcp(client_key) + observable = { + "name", + "description", + "enabled", + "transport", + "url", + "command", + "args", + "cwd", + "tools", + } + mismatched = sorted( + key + for key in observable & desired.keys() + if actual.get(key) != desired.get(key) + ) + if mismatched: + raise QwenPawApiError( + f"QwenPaw MCP {client_key} readback mismatch: {', '.join(mismatched)}", + ) + return actual + + def delete_mcp(self, client_key: str) -> None: + self._request( + "DELETE", + f"/api/mcp/{urllib.parse.quote(client_key, safe='')}", + ) + if any(item.get("key") == client_key for item in self.list_mcp()): + raise QwenPawApiError( + f"QwenPaw MCP {client_key} delete readback mismatch", + ) + + def get_mcp_policy(self, client_key: str) -> dict[str, Any]: + return self._request( + "GET", + f"/api/mcp/policy/{urllib.parse.quote(client_key, safe='')}", + ) + + def put_mcp_policy( + self, + client_key: str, + desired: dict[str, Any], + ) -> dict[str, Any]: + self._request( + "PUT", + f"/api/mcp/policy/{urllib.parse.quote(client_key, safe='')}", + desired, + ) + actual = self.get_mcp_policy(client_key) + mismatched = sorted( + key for key, value in desired.items() if actual.get(key) != value + ) + if mismatched: + raise QwenPawApiError( + f"QwenPaw MCP policy {client_key} readback mismatch: " + f"{', '.join(mismatched)}", + ) + return actual + + def configure_active_model( + self, + provider_id: str, + model: str, + *, + base_url: str = "", + api_key: str = "", + provider_name: str = "", + chat_model: str = "OpenAIChatModel", + ) -> dict[str, Any]: + providers = self._request("GET", "/api/models") + provider = next( + (item for item in providers if item.get("id") == provider_id), + None, + ) + if provider is None: + self._request( + "POST", + "/api/models/custom-providers", + { + "id": provider_id, + "name": provider_name or provider_id, + "default_base_url": base_url, + "chat_model": chat_model, + "models": [{"id": model, "name": model}], + }, + ) + else: + known_models = { + str(item.get("id")) + for item in list(provider.get("models") or []) + + list(provider.get("extra_models") or []) + } + if model not in known_models: + self._request( + "POST", + f"/api/models/{urllib.parse.quote(provider_id, safe='')}/models", + {"id": model, "name": model}, + ) + config_payload: dict[str, Any] = {"chat_model": chat_model} + if api_key: + config_payload["api_key"] = api_key + if base_url: + config_payload["base_url"] = base_url + self._request( + "PUT", + f"/api/models/{urllib.parse.quote(provider_id, safe='')}/config", + config_payload, + ) + self._request( + "PUT", + "/api/models/active", + { + "provider_id": provider_id, + "model": model, + "scope": "agent", + "agent_id": "default", + }, + ) + actual = self._request( + "GET", + "/api/models/active?scope=agent&agent_id=default", + ) + active = (actual or {}).get("active_llm") or {} + if active.get("provider_id") != provider_id or active.get("model") != model: + raise QwenPawApiError("QwenPaw active model readback mismatch") + providers = self._request("GET", "/api/models") + provider = next( + (item for item in providers if item.get("id") == provider_id), + None, + ) + if provider is None: + raise QwenPawApiError("QwenPaw model provider readback mismatch") + known_models = { + str(item.get("id")) + for item in list(provider.get("models") or []) + + list(provider.get("extra_models") or []) + } + if model not in known_models: + raise QwenPawApiError("QwenPaw provider model readback mismatch") + if base_url and str(provider.get("base_url") or "").rstrip("/") != base_url.rstrip("/"): + raise QwenPawApiError("QwenPaw provider base URL readback mismatch") + return actual + + def configure_agent( + self, + agent_id: str, + updates: dict[str, Any], + ) -> dict[str, Any]: + encoded = urllib.parse.quote(agent_id, safe="") + current = self._request("GET", f"/api/agents/{encoded}") + payload = {**current, **updates, "id": agent_id} + self._request("PUT", f"/api/agents/{encoded}", payload) + actual = self._request("GET", f"/api/agents/{encoded}") + mismatched = [ + key for key, value in updates.items() if actual.get(key) != value + ] + if mismatched: + raise QwenPawApiError( + f"QwenPaw agent {agent_id} readback mismatch: " + f"{', '.join(sorted(mismatched))}", + ) + return actual + + def disable_agent_if_present( + self, + agent_id: str, + *, + retries: int = 120, + retry_delay: float = 1.0, + ) -> bool: + agents = self._request("GET", "/api/agents").get("agents") or [] + current = next( + (agent for agent in agents if agent.get("id") == agent_id), + None, + ) + if current is None: + return False + if current.get("enabled", True): + encoded = urllib.parse.quote(agent_id, safe="") + for attempt in range(retries + 1): + try: + self._request( + "PATCH", + f"/api/agents/{encoded}/toggle", + {"enabled": False}, + ) + break + except QwenPawApiError as exc: + if "HTTP 409" not in str(exc) or attempt == retries: + raise + time.sleep(retry_delay) + agents = self._request("GET", "/api/agents").get("agents") or [] + actual = next( + (agent for agent in agents if agent.get("id") == agent_id), + None, + ) + if actual is None or actual.get("enabled", True): + raise QwenPawApiError( + f"QwenPaw agent {agent_id} disable readback mismatch", + ) + return True + + def sync_plugin(self, plugin_id: str) -> dict[str, Any]: + encoded = urllib.parse.quote(plugin_id, safe="") + return self._request("POST", f"/api/{encoded}/sync", {}) + + def refresh_and_enable_skills( + self, + skill_names: Iterable[str], + ) -> dict[str, Any]: + self._request("POST", "/api/skills/refresh", {}) + names = sorted(set(skill_names)) + if not names: + return {"results": {}} + result = self._request("POST", "/api/skills/batch-enable", names) + failed = sorted( + name + for name, value in (result.get("results") or {}).items() + if not isinstance(value, dict) or not value.get("success") + ) + if failed: + raise QwenPawApiError( + f"QwenPaw skill enable failed: {', '.join(failed)}", + ) + return result diff --git a/qwenpaw/src/qwenpaw_worker/sync.py b/qwenpaw/src/qwenpaw_worker/sync.py index 0b96d5c67..bff1371ca 100644 --- a/qwenpaw/src/qwenpaw_worker/sync.py +++ b/qwenpaw/src/qwenpaw_worker/sync.py @@ -275,7 +275,10 @@ def push_local(sync: FileSync, since: float = 0) -> List[str]: async def push_loop(sync: FileSync, check_interval: float = 5) -> None: - last_push_time = 0.0 + # Startup state has just been mirrored from object storage. Only watch + # changes made after the loop starts; a since=0 scan can spend minutes + # comparing a QwenPaw 2 workdir and starve newly-created files. + last_push_time = time.time() logger.info( "qwenpaw FileSync push loop started component=sync worker=%s interval_seconds=%s", sync.worker_name, diff --git a/qwenpaw/src/qwenpaw_worker/update.py b/qwenpaw/src/qwenpaw_worker/update.py index 37ff20be9..72d307816 100644 --- a/qwenpaw/src/qwenpaw_worker/update.py +++ b/qwenpaw/src/qwenpaw_worker/update.py @@ -21,7 +21,9 @@ import zipfile from pathlib import Path from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple -from urllib.parse import parse_qs, quote, urlencode, urlparse +from urllib.parse import parse_qs, urlencode, urlparse + +from qwenpaw_worker.api import QwenPawApiClient import yaml @@ -906,12 +908,11 @@ def _apply_to_workspace(self, package_dir: Path, previous_package_dir: Optional[ self._copy_config_to_workspace(config_dir) self._clear_missing_package_prompt_files(package_root) - self._apply_package_mcp_config(package_root, previous_root) + self._package_mcp_clients(package_root) skills_dir = package_root / "skills" if skills_dir.is_dir(): - skill_names = self._copy_skills_to_workspace(skills_dir) - self._reconcile_workspace_skills(skill_names) + self._copy_skills_to_workspace(skills_dir) def _package_content_root(self, package_dir: Path) -> Path: if self._looks_like_agent_package(package_dir): @@ -966,7 +967,6 @@ def _workspace_targets(self, package_dir: Path) -> List[Path]: skills_dir = package_root / "skills" if skills_dir.is_dir(): targets.extend(workspace_dir / "skills" / source.name for source in skills_dir.iterdir() if source.is_dir()) - targets.append(workspace_dir / "skill.json") return self._dedupe_paths(targets) @@ -974,8 +974,6 @@ def _workspace_state_targets(self, package_dir: Path) -> List[Path]: if self.workspace_dir is None: return [] package_root = self._package_content_root(package_dir) - if self._package_mcp_clients(package_root): - return [self.workspace_dir / "agent.json"] return [] def _snapshot_workspace( @@ -1114,29 +1112,6 @@ def _clear_missing_package_prompt_files(self, package_root: Path) -> None: target.parent.mkdir(parents=True, exist_ok=True) target.write_text("", encoding="utf-8") - def _apply_package_mcp_config(self, package_root: Path, previous_package_root: Optional[Path]) -> None: - current_clients = self._package_mcp_clients(package_root) - previous_clients = self._package_mcp_clients(previous_package_root) - if not current_clients and not previous_clients: - return - try: - from qwenpaw.config.config import MCPClientConfig, MCPConfig, load_agent_config, save_agent_config - except ImportError: - logger.info("qwenpaw package unavailable component=update step=apply_package_mcp action=skip") - return - - agent_config = load_agent_config(DEFAULT_AGENT_ID) - if getattr(agent_config, "mcp", None) is None: - agent_config.mcp = MCPConfig() - clients = dict(getattr(agent_config.mcp, "clients", None) or {}) - for name in previous_clients: - if name not in current_clients: - clients.pop(name, None) - for name, payload in current_clients.items(): - clients[name] = MCPClientConfig(**payload) - agent_config.mcp.clients = clients - save_agent_config(DEFAULT_AGENT_ID, agent_config) - def _package_mcp_clients(self, package_root: Optional[Path]) -> Dict[str, Dict[str, Any]]: if package_root is None: return {} @@ -1179,6 +1154,19 @@ def _package_mcp_clients(self, package_root: Optional[Path]) -> Dict[str, Dict[s clients[client_name] = self._qwenpaw_mcp_client_payload(client_name, item) return clients + def package_mcp_clients(self, package_dir: Optional[Path]) -> Dict[str, Dict[str, Any]]: + if package_dir is None: + return {} + return self._package_mcp_clients(self._package_content_root(package_dir)) + + def package_skill_names(self, package_dir: Optional[Path]) -> List[str]: + if package_dir is None: + return [] + skills_dir = self._package_content_root(package_dir) / "skills" + if not skills_dir.is_dir(): + return [] + return sorted(path.name for path in skills_dir.iterdir() if path.is_dir()) + def _qwenpaw_mcp_client_payload(self, name: str, item: Dict[str, Any]) -> Dict[str, Any]: payload = dict(item) payload.pop("id", None) @@ -1196,6 +1184,8 @@ def _qwenpaw_mcp_client_payload(self, name: str, item: Dict[str, Any]) -> Dict[s payload["transport"] = payload.pop("type") else: payload.pop("type", None) + if _string(payload.get("transport")).lower() == "http": + payload["transport"] = "streamable_http" payload = self._expand_mcp_workspace_placeholders(payload) self._ensure_mcp_stdio_workspace_env(payload) return payload @@ -1260,41 +1250,6 @@ def _replace_path(self, source: Path, target: Path) -> None: elif source.is_file(): shutil.copy2(source, target) - def _reconcile_workspace_skills(self, skill_names: Optional[List[str]] = None) -> None: - if self.workspace_dir is None: - return - try: - from qwenpaw.agents.skill_system.registry import reconcile_workspace_manifest - except ImportError: - logger.info("qwenpaw package unavailable component=update step=reconcile_workspace_skills action=skip") - return - reconcile_workspace_manifest(self.workspace_dir) - self._enable_workspace_skills(skill_names or []) - - def _enable_workspace_skills(self, skill_names: List[str]) -> None: - if self.workspace_dir is None or not skill_names: - return - manifest_path = self.workspace_dir / "skill.json" - if not manifest_path.exists(): - return - try: - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - except Exception: - logger.warning("workspace skill manifest is invalid component=update step=enable_workspace_skills action=skip") - return - skills = manifest.setdefault("skills", {}) - changed = False - for skill_name in skill_names: - entry = skills.get(skill_name) - if isinstance(entry, dict) and entry.get("enabled") is not True: - entry["enabled"] = True - changed = True - if changed: - tmp = manifest_path.with_name(f".{manifest_path.name}.tmp") - tmp.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") - tmp.replace(manifest_path) - - @dataclass(frozen=True) class ApplyResult: runtime_config: MemberRuntimeConfig @@ -1302,234 +1257,6 @@ class ApplyResult: agent_package_dir: Optional[Path] -@dataclass(frozen=True) -class _QwenPawApiResponse: - status: int - payload: Any - text: str - - -class QwenPawModelRuntimeSync: - """Sync runtime model state into the running QwenPaw app.""" - - def __init__( - self, - port: int, - agent_id: str = DEFAULT_AGENT_ID, - timeout: float = 10.0, - ) -> None: - self.api_root = f"http://127.0.0.1:{port}/api/models" - self.agent_id = agent_id - self.timeout = timeout - - def __call__(self, runtime_config: MemberRuntimeConfig) -> None: - self.sync(runtime_config) - - def sync(self, runtime_config: MemberRuntimeConfig) -> None: - started_at = time.monotonic() - fields = self._model_fields(runtime_config) - if fields is None: - return - provider_id, model_name, provider_name, base_url, api_key, chat_model = fields - if not base_url or not api_key: - logger.info( - "runtime model sync skipped component=update step=model_runtime_sync event=skip " - "generation=%s provider_id=%s model=%s reason=missing_provider_config", - runtime_config.generation, - provider_id, - model_name, - ) - return - - provider_count = self._provider_count(self._request("GET", "").payload) - provider_info = self._configure_provider(provider_id, provider_name, base_url, api_key, chat_model) - if not self._provider_has_model(provider_info, model_name): - provider_info = self._add_model(provider_id, model_name) - self._set_active_model(provider_id, model_name) - self._verify_active_model(provider_id, model_name) - logger.info( - "runtime model sync complete component=update step=model_runtime_sync event=complete " - "generation=%s provider_id=%s model=%s provider_count=%s changed=%s duration_ms=%s", - runtime_config.generation, - provider_id, - model_name, - provider_count, - True, - _duration_ms(started_at), - ) - - def _model_fields(self, runtime_config: MemberRuntimeConfig) -> Optional[Tuple[str, str, str, str, str, str]]: - model = runtime_config.model - provider_id = _string(model.get("providerId") or model.get("provider_id") or model.get("provider")) - model_name = _string(model.get("model") or model.get("name")) - if not provider_id or not model_name: - return None - base_url = _string( - model.get("baseUrl") - or model.get("base_url") - or model.get("gatewayUrl") - or model.get("gateway_url") - or model.get("endpoint") - or os.getenv("AGENTTEAMS_AI_GATEWAY_URL") - ) - api_key = _string(model.get("apiKey") or model.get("api_key")) - api_key_env = _string( - model.get("apiKeyEnv") - or model.get("api_key_env") - or runtime_config.credentials.get("gatewayKeyEnv") - or "AGENTTEAMS_WORKER_GATEWAY_KEY" - ) - if not api_key and api_key_env: - api_key = _string(os.getenv(api_key_env)) - return ( - provider_id, - model_name, - _string(model.get("providerName") or model.get("provider_name") or provider_id), - self._openai_compatible_base_url(base_url) if base_url else "", - api_key, - _string(model.get("chatModel") or model.get("chat_model") or "OpenAIChatModel"), - ) - - def _configure_provider( - self, - provider_id: str, - provider_name: str, - base_url: str, - api_key: str, - chat_model: str, - ) -> Dict[str, Any]: - payload = {"api_key": api_key, "base_url": base_url, "chat_model": chat_model} - path = f"/{quote(provider_id, safe='')}/config" - response = self._request("PUT", path, payload, ok_statuses=(200, 404)) - if response.status == 404: - self._request( - "POST", - "/custom-providers", - { - "id": provider_id, - "name": provider_name, - "default_base_url": base_url, - "api_key_prefix": "", - "chat_model": chat_model, - "models": [], - }, - ok_statuses=(200, 201), - ) - response = self._request("PUT", path, payload) - return response.payload if isinstance(response.payload, dict) else {} - - def _add_model(self, provider_id: str, model_name: str) -> Dict[str, Any]: - response = self._request( - "POST", - f"/{quote(provider_id, safe='')}/models", - {"id": model_name, "name": model_name}, - ok_statuses=(200, 201, 400, 404, 409, 422), - ) - if response.status >= 400 and not self._already_exists(response.text): - raise RuntimeError( - f"qwenpaw model runtime sync request failed: POST provider model HTTP {response.status}" - ) - return response.payload if isinstance(response.payload, dict) else {} - - def _set_active_model(self, provider_id: str, model_name: str) -> None: - self._request( - "PUT", - "/active", - { - "scope": "agent", - "agent_id": self.agent_id, - "provider_id": provider_id, - "model": model_name, - }, - ) - - def _verify_active_model(self, provider_id: str, model_name: str) -> None: - response = self._request( - "GET", - f"/active?{urlencode({'scope': 'effective', 'agent_id': self.agent_id})}", - ) - payload = response.payload if isinstance(response.payload, dict) else {} - active = payload.get("active_llm") if isinstance(payload.get("active_llm"), dict) else {} - actual_provider = _string(active.get("provider_id") or active.get("providerId")) - actual_model = _string(active.get("model")) - if actual_provider != provider_id or actual_model != model_name: - raise RuntimeError("qwenpaw model runtime sync verification failed") - - def _request( - self, - method: str, - path: str, - payload: Optional[Dict[str, Any]] = None, - ok_statuses: Iterable[int] = (200, 201), - ) -> _QwenPawApiResponse: - body = json.dumps(payload).encode("utf-8") if payload is not None else None - headers = {"Accept": "application/json"} - if body is not None: - headers["Content-Type"] = "application/json" - request = urllib.request.Request( - f"{self.api_root}{path}", - data=body, - headers=headers, - method=method, - ) - try: - with urllib.request.urlopen(request, timeout=self.timeout) as response: - text = response.read().decode("utf-8", errors="replace") - return _QwenPawApiResponse( - status=response.status, - payload=self._json_payload(text, response.status), - text=text, - ) - except urllib.error.HTTPError as exc: - text = exc.read().decode("utf-8", errors="replace") - if exc.code in ok_statuses: - return _QwenPawApiResponse( - status=exc.code, - payload=self._json_payload(text, exc.code), - text=text, - ) - raise RuntimeError( - f"qwenpaw model runtime sync request failed: {method} {path.split('?', 1)[0]} HTTP {exc.code}" - ) from None - except urllib.error.URLError as exc: - raise RuntimeError( - f"qwenpaw model runtime sync request failed: {method} {path.split('?', 1)[0]} " - f"{type(exc.reason).__name__}" - ) from None - - def _json_payload(self, text: str, status: int) -> Any: - if not text: - return {} - try: - return json.loads(text) - except json.JSONDecodeError as exc: - if status >= 400: - return {} - raise RuntimeError("qwenpaw model runtime sync request failed: invalid JSON response") from exc - - def _provider_count(self, payload: Any) -> int: - return len(payload) if isinstance(payload, list) else 0 - - def _provider_has_model(self, payload: Dict[str, Any], model_name: str) -> bool: - for key in ("models", "extra_models"): - value = payload.get(key) - if not isinstance(value, list): - continue - for item in value: - if isinstance(item, dict) and _string(item.get("id")) == model_name: - return True - return False - - def _already_exists(self, text: str) -> bool: - lower = text.lower() - return "already" in lower or "exist" in lower or "duplicate" in lower - - def _openai_compatible_base_url(self, base_url: str) -> str: - value = base_url.rstrip("/") - if value.endswith("/v1"): - return value - return f"{value}/v1" - class RuntimeUpdater: """Apply controller-projected runtime desired state inside one worker pod.""" @@ -1540,14 +1267,14 @@ def __init__( adapter_apply: Optional[Callable[[], None]] = None, package_manager: Optional[AgentPackageManager] = None, runtime_config_pull: Optional[Callable[[], None]] = None, - model_runtime_sync: Optional[Callable[[MemberRuntimeConfig], None]] = None, team_context_renderer: Optional[Callable[[MemberRuntimeConfig], str]] = None, + api_client: Optional[QwenPawApiClient] = None, ) -> None: self.config = config self.adapter_apply = adapter_apply self.runtime_config_pull = runtime_config_pull - self.model_runtime_sync = model_runtime_sync or QwenPawModelRuntimeSync(config.console_port) self.team_context_renderer = team_context_renderer + self.api_client = api_client self.package_manager = package_manager or AgentPackageManager( config.qwenpaw_working_dir / "agent-packages", workspace_dir=config.default_workspace_dir, @@ -1614,6 +1341,8 @@ def apply_once( self._apply_team_context_prompt(config) applied_package = self.package_manager.apply(config) + self._apply_package_mcp_servers(applied_package) + self._apply_package_skills(applied_package) self._apply_inline_config(config) adapter_applied = False @@ -1621,7 +1350,6 @@ def apply_once( self.adapter_apply() adapter_applied = True - self._sync_model_runtime_if_needed(previous, config) self.current_config = config logger.info( "runtime config apply complete component=update worker=%s generation=%s changed=%s " @@ -1655,31 +1383,6 @@ def _apply_inline_config(self, config: MemberRuntimeConfig) -> None: tmp.write_text(f"{content.rstrip()}\n", encoding="utf-8") tmp.replace(path) - def _sync_model_runtime_if_needed( - self, - previous: Optional[MemberRuntimeConfig], - config: MemberRuntimeConfig, - ) -> None: - if previous is None or self.model_runtime_sync is None: - return - if _stable_json(config.model) == _stable_json(previous.model): - return - started_at = time.monotonic() - try: - self.model_runtime_sync(config) - except Exception as exc: - logger.warning( - "runtime model sync failed component=update step=model_runtime_sync event=failed " - "worker=%s generation=%s changed=%s error_type=%s safe_error_summary=%s duration_ms=%s", - self.config.worker_name, - config.generation, - True, - type(exc).__name__, - type(exc).__name__, - _duration_ms(started_at), - ) - raise - def _adapter_neutral_change(self, config: MemberRuntimeConfig) -> bool: previous = self.current_config if previous is None: @@ -1805,25 +1508,8 @@ def _apply_model(self, config: MemberRuntimeConfig) -> None: model_name = _string(model.get("model") or model.get("name")) if not provider_id or not model_name: return - try: - from qwenpaw.config.config import ModelSlotConfig, load_agent_config, save_agent_config - except ImportError: - logger.info("qwenpaw package unavailable component=update step=apply_model action=skip") - return - - agent_config = load_agent_config(DEFAULT_AGENT_ID) - agent_config.active_model = ModelSlotConfig(provider_id=provider_id, model=model_name) - save_agent_config(DEFAULT_AGENT_ID, agent_config) - self._apply_openai_compatible_provider(config, provider_id, model_name, ModelSlotConfig) - - def _apply_openai_compatible_provider( - self, - config: MemberRuntimeConfig, - provider_id: str, - model_name: str, - model_slot_config_class: Any, - ) -> None: - model = config.model + if self.api_client is None: + raise RuntimeError("QwenPaw API client is required for model configuration") base_url = _string( model.get("baseUrl") or model.get("base_url") @@ -1841,33 +1527,14 @@ def _apply_openai_compatible_provider( ) if not api_key and api_key_env: api_key = _string(os.getenv(api_key_env)) - if not base_url or not api_key: - return - - try: - from qwenpaw.providers.provider import ModelInfo, ProviderInfo - from qwenpaw.providers.provider_manager import ProviderManager - except ImportError: - logger.info("qwenpaw provider package unavailable component=update step=apply_provider action=skip") - return - - manager = ProviderManager.get_instance() - provider_data = ProviderInfo( - id=provider_id, - name=_string(model.get("providerName") or model.get("provider_name") or provider_id), - base_url=self._openai_compatible_base_url(base_url), + self.api_client.configure_active_model( + provider_id, + model_name, + base_url=self._openai_compatible_base_url(base_url) if base_url else "", api_key=api_key, + provider_name=_string(model.get("providerName") or model.get("provider_name") or provider_id), chat_model=_string(model.get("chatModel") or model.get("chat_model") or "OpenAIChatModel"), - models=[ModelInfo(id=model_name, name=model_name)], - is_custom=True, - support_model_discovery=False, - support_connection_check=False, ) - provider = manager._provider_from_data(provider_data.model_dump()) - manager.custom_providers[provider_id] = provider - manager.save_provider_config(provider_id, provider) - manager.active_model = model_slot_config_class(provider_id=provider_id, model=model_name) - manager.save_active_model(manager.active_model) def _openai_compatible_base_url(self, base_url: str) -> str: value = base_url.rstrip("/") @@ -1877,15 +1544,82 @@ def _openai_compatible_base_url(self, base_url: str) -> str: def _apply_mcp_servers(self, config: MemberRuntimeConfig) -> None: servers = self._mcporter_servers(config) - legacy_path = self.config.default_workspace_dir / "mcporter-servers.json" - if legacy_path.exists(): - legacy_path.unlink() + if self.api_client is None: + if servers: + raise RuntimeError("QwenPaw API client is required for MCP configuration") + return + existing = {str(item.get("key")): item for item in self.api_client.list_mcp()} + ownership_path = self.config.qwenpaw_working_dir / ".agentteams-managed-mcp.json" + try: + managed = set(json.loads(ownership_path.read_text(encoding="utf-8"))) + except (FileNotFoundError, json.JSONDecodeError, TypeError): + managed = set() + for key in sorted((managed & existing.keys()) - servers.keys()): + self.api_client.delete_mcp(key) + for key, server in servers.items(): + transport = _string(server.get("transport") or "http") + payload = { + "name": key, + "enabled": True, + "transport": "streamable_http" + if transport in {"http", "streamable_http"} + else transport, + "url": _string(server.get("url")), + "headers": dict(server.get("headers") or {}), + "command": _string(server.get("command")), + "args": list(server.get("args") or []), + "env": dict(server.get("env") or {}), + "cwd": _string(server.get("cwd")), + } + if key in existing: + self.api_client.update_mcp(key, payload) + else: + self.api_client.create_mcp(key, payload) + ownership_path.parent.mkdir(parents=True, exist_ok=True) + ownership_path.write_text( + json.dumps(sorted(servers), ensure_ascii=False) + "\n", + encoding="utf-8", + ) - path = self.config.default_workspace_dir / "config" / "mcporter.json" - payload = {"mcpServers": servers} - text = json.dumps(payload, indent=2, ensure_ascii=False) + "\n" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text, encoding="utf-8") + def _apply_package_mcp_servers(self, package_dir: Optional[Path]) -> None: + package_clients = getattr(self.package_manager, "package_mcp_clients", None) + servers = package_clients(package_dir) if callable(package_clients) else {} + if self.api_client is None: + if servers: + raise RuntimeError( + "QwenPaw API client is required for agent package MCP configuration", + ) + return + existing = {str(item.get("key")): item for item in self.api_client.list_mcp()} + ownership_path = ( + self.config.qwenpaw_working_dir / ".agentteams-managed-package-mcp.json" + ) + try: + managed = set(json.loads(ownership_path.read_text(encoding="utf-8"))) + except (FileNotFoundError, json.JSONDecodeError, TypeError): + managed = set() + for key in sorted((managed & existing.keys()) - servers.keys()): + self.api_client.delete_mcp(key) + for key, server in servers.items(): + payload = dict(server) + payload.setdefault("name", key) + if key in existing: + self.api_client.update_mcp(key, payload) + else: + self.api_client.create_mcp(key, payload) + ownership_path.parent.mkdir(parents=True, exist_ok=True) + ownership_path.write_text( + json.dumps(sorted(servers), ensure_ascii=False) + "\n", + encoding="utf-8", + ) + + def _apply_package_skills(self, package_dir: Optional[Path]) -> None: + if package_dir is None or self.api_client is None: + return + package_skills = getattr(self.package_manager, "package_skill_names", None) + skill_names = package_skills(package_dir) if callable(package_skills) else [] + if skill_names: + self.api_client.refresh_and_enable_skills(skill_names) def _apply_channel_policy(self, config: MemberRuntimeConfig) -> None: group_allow, dm_allow, group_deny, dm_deny = self._matrix_policy_ids(config) @@ -1908,25 +1642,16 @@ def _apply_matrix_channel(self, config: MemberRuntimeConfig) -> None: desired = self._matrix_channel_desired_state(config) if desired is None: return - try: - from qwenpaw.config.config import load_agent_config, save_agent_config - except ImportError: - logger.info("qwenpaw package unavailable component=update step=apply_matrix_channel action=skip") - return - - agent_config = load_agent_config(DEFAULT_AGENT_ID) - if getattr(agent_config, "channels", None) is None: - try: - from qwenpaw.config.config import ChannelConfig - except ImportError: - return - agent_config.channels = ChannelConfig() - matrix_config = getattr(agent_config.channels, "matrix", None) - if matrix_config is None: - return - - changed = False - desired_fields = { + if self.api_client is None: + raise RuntimeError("QwenPaw API client is required for Matrix configuration") + groups: Dict[str, Any] = {} + self._ensure_require_mention_group(groups, "*") + room_id = desired["room_id"] + if room_id: + self._ensure_require_mention_group(groups, room_id) + self.api_client.put_channel( + "agentteams_matrix", + { "enabled": True, "homeserver": desired["homeserver"], "user_id": desired["user_id"], @@ -1935,56 +1660,27 @@ def _apply_matrix_channel(self, config: MemberRuntimeConfig) -> None: "encryption": _env_bool("AGENTTEAMS_MATRIX_E2EE"), "group_disabled": False, "dm_disabled": False, - "filter_tool_messages": False, - "filter_thinking": False, - } - for field, value in desired_fields.items(): - if getattr(matrix_config, field, None) != value: - setattr(matrix_config, field, value) - changed = True - - groups = dict(getattr(matrix_config, "groups", None) or {}) - if self._ensure_require_mention_group(groups, "*"): - changed = True - room_id = desired["room_id"] - if room_id: - if self._ensure_require_mention_group(groups, room_id): - changed = True - if getattr(matrix_config, "groups", None) != groups: - matrix_config.groups = groups - changed = True - - if changed: - save_agent_config(DEFAULT_AGENT_ID, agent_config) + "show_tool_calls": True, + "show_tool_results": True, + "show_thinking": True, + "groups": groups, + }, + secret_fields={"access_token", "password"}, + ) def _apply_dingtalk_channel(self, config: MemberRuntimeConfig) -> None: desired = config.dingtalk_channel if desired is None: return - try: - from qwenpaw.config.config import load_agent_config, save_agent_config - except ImportError: - logger.info("qwenpaw package unavailable component=update step=apply_dingtalk_channel action=skip") - return - - agent_config = load_agent_config(DEFAULT_AGENT_ID) - if getattr(agent_config, "channels", None) is None: - try: - from qwenpaw.config.config import ChannelConfig - except ImportError: - return - agent_config.channels = ChannelConfig() - dingtalk_config = getattr(agent_config.channels, "dingtalk", None) - if dingtalk_config is None: - return - - changed = False + if self.api_client is None: + raise RuntimeError("QwenPaw API client is required for DingTalk configuration") + current = self.api_client.get_channel("dingtalk") if not _bool(desired.get("enabled")): - if getattr(dingtalk_config, "enabled", None) is not False: - dingtalk_config.enabled = False - changed = True - if changed: - save_agent_config(DEFAULT_AGENT_ID, agent_config) + self.api_client.put_channel( + "dingtalk", + {**current, "enabled": False}, + secret_fields={"client_secret"}, + ) return streaming_enabled = _bool(desired.get("streaming_enabled")) @@ -1996,8 +1692,9 @@ def _apply_dingtalk_channel(self, config: MemberRuntimeConfig) -> None: "client_id": client_id, "client_secret": client_secret, "robot_code": robot_code, - "filter_thinking": _bool(desired.get("filter_thinking")), - "filter_tool_messages": _bool(desired.get("filter_tool_messages")), + "show_thinking": not _bool(desired.get("filter_thinking")), + "show_tool_calls": not _bool(desired.get("filter_tool_messages")), + "show_tool_results": not _bool(desired.get("filter_tool_messages")), "streaming_enabled": streaming_enabled, } if streaming_enabled: @@ -2020,8 +1717,8 @@ def _apply_dingtalk_channel(self, config: MemberRuntimeConfig) -> None: f"{', '.join(missing)}" ) card_template_id = _string(desired.get("card_template_id")) - previous_message_type = _string(getattr(dingtalk_config, "message_type", "")) - previous_template_id = _string(getattr(dingtalk_config, "card_template_id", "")) + previous_message_type = _string(current.get("message_type")) + previous_template_id = _string(current.get("card_template_id")) if ( previous_message_type == "card" and previous_template_id @@ -2055,13 +1752,11 @@ def _apply_dingtalk_channel(self, config: MemberRuntimeConfig) -> None: ) if "card_auto_layout" in desired: desired_fields["card_auto_layout"] = _bool(desired.get("card_auto_layout")) - for field, value in desired_fields.items(): - if getattr(dingtalk_config, field, None) != value: - setattr(dingtalk_config, field, value) - changed = True - - if changed: - save_agent_config(DEFAULT_AGENT_ID, agent_config) + self.api_client.put_channel( + "dingtalk", + {**current, **desired_fields}, + secret_fields={"client_secret"}, + ) def _matrix_channel_desired_state(self, config: MemberRuntimeConfig) -> Optional[Dict[str, str]]: homeserver = _string( @@ -2228,55 +1923,23 @@ def _gateway_key(self, config: MemberRuntimeConfig) -> str: return os.getenv(env_name, "") if env_name else "" def _apply_matrix_channel_access_flags(self, group_enabled: bool, dm_enabled: bool) -> None: - try: - from qwenpaw.config.config import load_agent_config, save_agent_config - except ImportError: - logger.info("qwenpaw package unavailable component=update step=apply_channel_access_flags action=skip") - return - - agent_config = load_agent_config(DEFAULT_AGENT_ID) - if getattr(agent_config, "channels", None) is None: - try: - from qwenpaw.config.config import ChannelConfig - except ImportError: - return - agent_config.channels = ChannelConfig() - matrix_config = getattr(agent_config.channels, "matrix", None) - if matrix_config is None: - return - matrix_config.access_control_group = group_enabled - matrix_config.access_control_dm = dm_enabled - save_agent_config(DEFAULT_AGENT_ID, agent_config) + if self.api_client is None: + raise RuntimeError("QwenPaw API client is required for Matrix ACL configuration") + current = self.api_client.get_channel("agentteams_matrix") + self.api_client.put_channel( + "agentteams_matrix", + { + **current, + "access_control_group": group_enabled, + "access_control_dm": dm_enabled, + }, + secret_fields={"access_token", "password"}, + ) def _write_matrix_access_control(self, whitelist: List[str], blacklist: List[str]) -> None: - try: - from qwenpaw.app.channels.access_control import get_access_control_store - except ImportError: - self._write_matrix_access_control_json(whitelist, blacklist) - return - - store = get_access_control_store(self.config.default_workspace_dir) - store.set_whitelist("matrix", whitelist) - store.set_blacklist("matrix", blacklist) - - def _write_matrix_access_control_json(self, whitelist: List[str], blacklist: List[str]) -> None: - path = self.config.default_workspace_dir / "access_control.json" - existing: Dict[str, Any] = {} - if path.exists(): - try: - loaded = json.loads(path.read_text(encoding="utf-8")) - existing = loaded if isinstance(loaded, dict) else {} - except json.JSONDecodeError: - existing = {} - matrix = _section(existing, "matrix") - pending = matrix.get("pending") - existing["matrix"] = { - "whitelist": {value: "" for value in whitelist}, - "blacklist": {value: "" for value in blacklist}, - "pending": pending if isinstance(pending, list) else [], - } - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(existing, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + if self.api_client is None: + raise RuntimeError("QwenPaw API client is required for Matrix ACL configuration") + self.api_client.reconcile_acl("agentteams_matrix", whitelist, blacklist) def _dedupe(self, values: List[str]) -> List[str]: result = [] diff --git a/qwenpaw/src/qwenpaw_worker/worker.py b/qwenpaw/src/qwenpaw_worker/worker.py index ec64a000a..0645aec20 100644 --- a/qwenpaw/src/qwenpaw_worker/worker.py +++ b/qwenpaw/src/qwenpaw_worker/worker.py @@ -4,7 +4,6 @@ import asyncio import hashlib -import importlib.util import logging import os from pathlib import Path @@ -16,6 +15,7 @@ import zipfile from typing import Optional +from qwenpaw_worker.api import QwenPawApiClient from qwenpaw_worker.config import WorkerConfig, _relative_storage_prefix from qwenpaw_worker.heartbeat import WorkerHeartbeat, run_worker_heartbeat_loop from qwenpaw_worker.sync import FileSync, push_loop @@ -24,10 +24,8 @@ logger = logging.getLogger(__name__) DEFAULT_AGENT_ID = "default" -BUILTIN_QWENPAW_QA_AGENT_ID = "QwenPaw_QA_Agent_0.2" DEFAULT_BUILTIN_QWENPAW_PLUGINS_DIR = Path("/opt/agentteams/qwenpaw-builtin/plugins") BUILTIN_QWENPAW_PLUGIN_MARKER = ".agentteams-builtin-plugin.sha256" -SENSITIVE_FILE_AUTO_DENY_RULE = "SENSITIVE_FILE_BLOCK" SESSION_FILE_PROMPT_POLICY = """Do not read, list, grep, glob, summarize, copy, or expose files under sessions/. Session files are runtime-private state and may contain private conversation history. This rule applies to all channels, users, and sessions, not only DingTalk.""" @@ -68,10 +66,13 @@ def __init__(self, config: WorkerConfig) -> None: self.config = config self.sync: Optional[FileSync] = None self.heartbeat = WorkerHeartbeat(config.qwenpaw_working_dir / "heartbeat.json") + self.api_client = QwenPawApiClient( + f"http://127.0.0.1:{config.console_port}", + ) self.updater = RuntimeUpdater( config=config, adapter_apply=self._apply_runtime_adapter, - team_context_renderer=self._render_teamharness_context, + api_client=self.api_client, ) self._process: Optional[asyncio.subprocess.Process] = None self._heartbeat_probe_task: Optional[asyncio.Task] = None @@ -79,6 +80,7 @@ def __init__(self, config: WorkerConfig) -> None: self._update_task: Optional[asyncio.Task] = None self._stopping = False self._workspace_shared_dir: Optional[Path] = None + self._initial_runtime_config: Optional[MemberRuntimeConfig] = None async def run(self) -> None: if not await self.start(): @@ -150,11 +152,11 @@ async def start(self) -> bool: self._apply_runtime_identity(runtime_config) self._apply_runtime_storage(runtime_config) + self._initial_runtime_config = runtime_config try: stage_started = self._log_worker_stage_begin("prepare_qwenpaw_runtime") self._link_workspace_shared() - self._configure_qwenpaw_runtime() except Exception as exc: self._log_worker_stage_failed("prepare_qwenpaw_runtime", stage_started, exc) self.heartbeat.update("not_ready", str(exc)) @@ -170,54 +172,16 @@ async def start(self) -> bool: return False self._log_worker_stage_complete("prepare_default_plugins", stage_started) - try: - stage_started = self._log_worker_stage_begin("apply_desired_state") - self.updater.apply_once(runtime_config=runtime_config, force=True, reapply_adapter=False) - self._ensure_session_file_prompt_policy() - except Exception as exc: - self._log_worker_stage_failed("apply_desired_state", stage_started, exc) - self.heartbeat.update("not_ready", str(exc)) - return False - self._log_worker_stage_complete("apply_desired_state", stage_started) - - try: - stage_started = self._log_worker_stage_begin("sync_teamharness_assets") - self._apply_teamharness_assets() - except Exception as exc: - self._log_worker_stage_failed("sync_teamharness_assets", stage_started, exc) - self.heartbeat.update("not_ready", str(exc)) - return False - self._log_worker_stage_complete("sync_teamharness_assets", stage_started) - - try: - stage_started = self._log_worker_stage_begin("sync_workerflow_assets") - self._apply_workerflow_assets() - except Exception as exc: - self._log_worker_stage_failed("sync_workerflow_assets", stage_started, exc) - self.heartbeat.update("not_ready", str(exc)) - return False - self._log_worker_stage_complete("sync_workerflow_assets", stage_started) - stage_started = self._log_worker_stage_begin("start_push_loop", interval_seconds=5) self._push_task = asyncio.create_task( push_loop(self.sync, check_interval=5), name=f"qwenpaw-worker-{self.config.worker_name}-push-loop", ) self._log_worker_stage_complete("start_push_loop", stage_started, interval_seconds=5) - stage_started = self._log_worker_stage_begin( - "start_update_loop", - interval_seconds=self.config.runtime_config_poll_interval, - ) - self._update_task = asyncio.create_task( - self.updater.loop(), - name=f"qwenpaw-worker-{self.config.worker_name}-update-loop", - ) - self._log_worker_stage_complete( - "start_update_loop", - stage_started, - interval_seconds=self.config.runtime_config_poll_interval, + logger.info( + "qwenpaw worker preparation complete component=worker worker=%s", + self.config.worker_name, ) - logger.info("qwenpaw worker startup complete component=worker worker=%s", self.config.worker_name) return True async def stop(self) -> None: @@ -411,102 +375,6 @@ def _apply_runtime_identity(self, runtime_config) -> None: os.environ["AGENTTEAMS_AGENT_ROLE"] = role os.environ["AGENTTEAMS_WORKER_ROLE"] = role - def _configure_qwenpaw_runtime(self) -> None: - try: - from qwenpaw.config.config import AgentProfileConfig, AgentProfileRef, load_agent_config, save_agent_config - from qwenpaw.config.utils import load_config, save_config - except ImportError: - logger.info("qwenpaw package unavailable component=worker step=configure_qwenpaw_runtime action=skip") - return - - root = load_config() - self._ensure_session_file_guard(root) - root.agents.active_agent = DEFAULT_AGENT_ID - root.agents.profiles[DEFAULT_AGENT_ID] = AgentProfileRef( - id=DEFAULT_AGENT_ID, - workspace_dir=str(self.config.default_workspace_dir), - enabled=True, - ) - self._disable_builtin_qwenpaw_qa_agent(root, AgentProfileRef) - if DEFAULT_AGENT_ID not in root.agents.agent_order: - root.agents.agent_order.insert(0, DEFAULT_AGENT_ID) - save_config(root) - - try: - agent_config = load_agent_config(DEFAULT_AGENT_ID) - except Exception: - agent_config = AgentProfileConfig( - id=DEFAULT_AGENT_ID, - name=self.config.agent_name, - description=f"AgentTeams QwenPaw {self.config.agent_role}", - workspace_dir=str(self.config.default_workspace_dir), - ) - - agent_config.name = self.config.agent_name - agent_config.workspace_dir = str(self.config.default_workspace_dir) - agent_config.approval_level = "AUTO" - prompt_files = list(agent_config.system_prompt_files or []) - for file_name in ("AGENTS.md", "SOUL.md", "TEAMS.md", "IDENTITY.md"): - if file_name not in prompt_files: - prompt_files.append(file_name) - agent_config.system_prompt_files = prompt_files - save_agent_config(DEFAULT_AGENT_ID, agent_config) - - def _disable_builtin_qwenpaw_qa_agent(self, root, agent_profile_ref_cls) -> None: - qa_workspace = ( - self.config.qwenpaw_working_dir / "workspaces" / BUILTIN_QWENPAW_QA_AGENT_ID - ) - profiles = root.agents.profiles - profile = profiles.get(BUILTIN_QWENPAW_QA_AGENT_ID) - if profile is None: - profiles[BUILTIN_QWENPAW_QA_AGENT_ID] = agent_profile_ref_cls( - id=BUILTIN_QWENPAW_QA_AGENT_ID, - workspace_dir=str(qa_workspace), - enabled=False, - ) - logger.info( - "preseeded disabled builtin QwenPaw QA agent profile component=worker " - "step=configure_qwenpaw_runtime agent_id=%s", - BUILTIN_QWENPAW_QA_AGENT_ID, - ) - return - - if getattr(profile, "enabled", True): - profile.enabled = False - logger.info( - "disabled builtin QwenPaw QA agent profile component=worker " - "step=configure_qwenpaw_runtime agent_id=%s", - BUILTIN_QWENPAW_QA_AGENT_ID, - ) - - def _ensure_session_file_guard(self, root) -> None: - security = root.security - file_guard = security.file_guard - tool_guard = security.tool_guard - - file_guard.enabled = True - tool_guard.enabled = True - tool_guard.guarded_tools = [] - - session_path = f"{self.config.default_workspace_dir / 'sessions'}/" - sensitive_files = [ - str(path) - for path in (file_guard.sensitive_files or []) - if str(path) - ] - if session_path not in sensitive_files: - sensitive_files.append(session_path) - file_guard.sensitive_files = sensitive_files - - auto_denied_rules = [ - str(rule) - for rule in (getattr(tool_guard, "auto_denied_rules", None) or []) - if str(rule) - ] - if SENSITIVE_FILE_AUTO_DENY_RULE not in auto_denied_rules: - auto_denied_rules.append(SENSITIVE_FILE_AUTO_DENY_RULE) - tool_guard.auto_denied_rules = auto_denied_rules - def _ensure_session_file_prompt_policy(self) -> None: self.config.default_workspace_dir.mkdir(parents=True, exist_ok=True) for file_name in ("AGENTS.md", "SOUL.md"): @@ -523,12 +391,16 @@ def _ensure_session_file_prompt_policy(self) -> None: def _apply_runtime_adapter(self) -> None: self._prepare_default_plugins() - self._apply_teamharness_assets() - self._apply_workerflow_assets() + self._configure_builtin_plugin_mcp_clients() + self._configure_builtin_plugin_mcp_policies() self._ensure_session_file_prompt_policy() def _prepare_default_plugins(self) -> None: builtin_root = self._builtin_qwenpaw_plugins_dir() + self._prepare_builtin_plugin( + "agentteams-matrix-channel", + builtin_root / "agentteams-matrix-channel", + ) self._prepare_builtin_plugin("teamharness", builtin_root / "teamharness") self._prepare_builtin_plugin("workerflow", builtin_root / "workerflow") @@ -678,82 +550,6 @@ def _extract_qwenpaw_plugin_zip(self, zip_path: Path, target_dir: Path) -> Path: raise RuntimeError(f"expected one qwenpaw plugin package in {zip_path}") return packages[0] - def _apply_teamharness_assets(self) -> dict: - return self._apply_plugin_assets( - plugin_name="teamharness", - module_name="agentteams_teamharness_qwenpaw_plugin", - entrypoint_name="apply_teamharness", - ) - - def _render_teamharness_context(self, runtime_config: MemberRuntimeConfig) -> str: - plugin_file = self.config.qwenpaw_working_dir / "plugins" / "teamharness" / "plugin.py" - if not plugin_file.is_file(): - return "" - - spec = importlib.util.spec_from_file_location("agentteams_teamharness_qwenpaw_plugin_renderer", plugin_file) - if spec is None or spec.loader is None: - return "" - - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - render = getattr(module, "render_team_context", None) - if not callable(render): - return "" - text = render(runtime_config.raw) - return text if isinstance(text, str) else "" - - def _apply_workerflow_assets(self) -> dict: - return self._apply_plugin_assets( - plugin_name="workerflow", - module_name="agentteams_workerflow_qwenpaw_plugin", - entrypoint_name="apply_workerflow", - ) - - def _apply_plugin_assets(self, *, plugin_name: str, module_name: str, entrypoint_name: str) -> dict: - plugin_file = self.config.qwenpaw_working_dir / "plugins" / plugin_name / "plugin.py" - step_started = self._log_plugin_step_begin( - plugin_name, - "load", - plugin_file=plugin_file, - plugin_file_exists=plugin_file.is_file(), - entrypoint=entrypoint_name, - ) - try: - if not plugin_file.is_file(): - raise RuntimeError(f"installed {plugin_name} qwenpaw plugin missing: {plugin_file}") - - spec = importlib.util.spec_from_file_location(module_name, plugin_file) - if spec is None or spec.loader is None: - raise RuntimeError(f"failed to load {plugin_name} qwenpaw plugin: {plugin_file}") - - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - apply_plugin = getattr(module, entrypoint_name, None) - if not callable(apply_plugin): - raise RuntimeError(f"installed {plugin_name} qwenpaw plugin has no {entrypoint_name}: {plugin_file}") - except Exception as exc: - self._log_plugin_step_failed(plugin_name, "load", step_started, exc, entrypoint=entrypoint_name) - raise - self._log_plugin_step_complete(plugin_name, "load", step_started, entrypoint=entrypoint_name) - - step_started = self._log_plugin_step_begin(plugin_name, "apply", entrypoint=entrypoint_name) - try: - result = apply_plugin() - if not isinstance(result, dict) or result.get("ok") is not True: - raise RuntimeError(f"{plugin_name} asset sync failed: {result!r}") - except Exception as exc: - self._log_plugin_step_failed(plugin_name, "apply", step_started, exc, entrypoint=entrypoint_name) - raise - self._log_plugin_step_complete( - plugin_name, - "apply", - step_started, - entrypoint=entrypoint_name, - ok=result.get("ok"), - result_key_count=len(result), - ) - return result - async def _run_qwenpaw(self) -> None: qwenpaw_bin = shutil.which("qwenpaw") or str(Path(sys.executable).with_name("qwenpaw")) host = "0.0.0.0" @@ -802,6 +598,72 @@ async def _run_qwenpaw(self) -> None: pid=getattr(self._process, "pid", "-"), port=self.config.console_port, ) + try: + await self._wait_for_qwenpaw_api() + await asyncio.to_thread( + self.api_client.configure_agent, + DEFAULT_AGENT_ID, + { + "name": self.config.agent_name, + "workspace_dir": str(self.config.default_workspace_dir), + "approval_level": "AUTO", + "system_prompt_files": ["AGENTS.md", "SOUL.md", "TEAMS.md"], + }, + ) + await asyncio.to_thread( + self.api_client.disable_agent_if_present, + "QwenPaw_QA_Agent_0.2", + ) + await asyncio.to_thread(self._configure_builtin_plugin_mcp_clients) + runtime_config = self._initial_runtime_config or self.updater.load() + stage_started = self._log_worker_stage_begin("apply_desired_state") + await asyncio.to_thread( + self.updater.apply_once, + runtime_config, + True, + False, + ) + await asyncio.to_thread(self._configure_builtin_plugin_mcp_policies) + self._ensure_session_file_prompt_policy() + self._log_worker_stage_complete("apply_desired_state", stage_started) + except Exception as exc: + if self._process.returncode is not None: + returncode = self._process.returncode + self.heartbeat.update( + "not_ready", + "qwenpaw app exited unexpectedly", + {"operation": "run_qwenpaw", "returncode": returncode}, + ) + logger.warning( + "qwenpaw app exited component=worker stage=start_qwenpaw_app " + "event=exited worker=%s returncode=%s stopping=False duration_ms=0", + self.config.worker_name, + returncode, + ) + return + self.heartbeat.update("not_ready", str(exc)) + if self._process.returncode is None: + self._process.terminate() + await self._process.wait() + raise + + stage_started = self._log_worker_stage_begin( + "start_update_loop", + interval_seconds=self.config.runtime_config_poll_interval, + ) + self._update_task = asyncio.create_task( + self.updater.loop(), + name=f"qwenpaw-worker-{self.config.worker_name}-update-loop", + ) + self._log_worker_stage_complete( + "start_update_loop", + stage_started, + interval_seconds=self.config.runtime_config_poll_interval, + ) + logger.info( + "qwenpaw worker startup complete component=worker worker=%s", + self.config.worker_name, + ) process_started_at = time.monotonic() self._heartbeat_probe_task = asyncio.create_task(self._heartbeat_probe_loop()) returncode = await self._process.wait() @@ -827,6 +689,69 @@ async def _run_qwenpaw(self) -> None: _duration_ms(process_started_at), ) + async def _wait_for_qwenpaw_api(self) -> None: + deadline = time.monotonic() + 60 + last_error: Optional[Exception] = None + while time.monotonic() < deadline: + if self._process is not None and self._process.returncode is not None: + raise RuntimeError( + f"qwenpaw app exited before API readiness: {self._process.returncode}", + ) + try: + await asyncio.to_thread(self.api_client.require_version, "2.0.1") + return + except Exception as exc: + last_error = exc + await asyncio.sleep(0.5) + raise RuntimeError(f"qwenpaw API did not become ready: {last_error}") + + def _configure_builtin_plugin_mcp_clients(self) -> None: + existing = {str(item.get("key")) for item in self.api_client.list_mcp()} + for plugin_id in ("teamharness", "workerflow"): + plugin_dir = self.config.qwenpaw_working_dir / "plugins" / plugin_id + asset_dir = plugin_dir / plugin_id + payload = { + "name": plugin_id, + "description": f"AgentTeams {plugin_id} MCP server", + "enabled": True, + "transport": "stdio", + "command": sys.executable, + "args": [str(asset_dir / "mcp" / "server.py")], + "env": { + name: value + for name in ( + "TEAMHARNESS_RUNTIME_CONFIG", + "TEAMHARNESS_SHARED_DIR", + "AGENTTEAMS_MATRIX_URL", + "AGENTTEAMS_WORKER_MATRIX_TOKEN", + "AGENTTEAMS_MATRIX_USER_ID", + "AGENTTEAMS_WORKER_ROLE", + "AGENTTEAMS_AGENT_ROLE", + "AGENTTEAMS_WORKER_NAME", + "AGENTTEAMS_SHARED_STORAGE_PREFIX", + "AGENTTEAMS_FS_BUCKET", + "AGENTTEAMS_FS_ENDPOINT", + "QWENPAW_WORKING_DIR", + ) + if (value := os.getenv(name, "").strip()) + }, + "cwd": str(asset_dir), + } + if plugin_id in existing: + self.api_client.update_mcp(plugin_id, payload) + else: + self.api_client.create_mcp(plugin_id, payload) + + def _configure_builtin_plugin_mcp_policies(self) -> None: + allow_policy = { + "default_effect": "allow", + "client_overrides": [], + "tool_defaults": [], + "tool_overrides": [], + } + for plugin_id in ("teamharness", "workerflow"): + self.api_client.put_mcp_policy(plugin_id, allow_policy) + async def _heartbeat_probe_loop(self) -> None: await run_worker_heartbeat_loop( self.heartbeat, diff --git a/qwenpaw/tests/integration/test-real-image-model-api.sh b/qwenpaw/tests/integration/test-real-image-model-api.sh index 823cc5084..a9922c74f 100755 --- a/qwenpaw/tests/integration/test-real-image-model-api.sh +++ b/qwenpaw/tests/integration/test-real-image-model-api.sh @@ -202,9 +202,9 @@ docker exec -i \ -e EXPECTED_BASE_URL="${BASE_URL}" \ "${WORKER_CONTAINER}" \ /opt/venv/qwenpaw/bin/python - <<'PY' +import json import os - -from qwenpaw.providers.provider_manager import ProviderManager +from urllib.request import urlopen expected_provider = os.environ["EXPECTED_PROVIDER"] expected_model = os.environ["EXPECTED_MODEL"] @@ -212,15 +212,18 @@ expected_base = os.environ["EXPECTED_BASE_URL"].rstrip("/") if not expected_base.endswith("/v1"): expected_base = f"{expected_base}/v1" -manager = ProviderManager.get_instance() -active = manager.active_model -provider = manager.custom_providers.get(expected_provider) +def get(path): + with urlopen(f"http://127.0.0.1:8088{path}") as response: + return json.load(response) + +active = get("/api/models/active?scope=agent&agent_id=default")["active_llm"] +provider = next( + item for item in get("/api/models") if item["id"] == expected_provider +) -assert active.provider_id == expected_provider, active -assert active.model == expected_model, active -assert provider is not None, expected_provider -assert provider.base_url == expected_base, provider.base_url -assert bool(provider.api_key), "provider api key missing" +assert active["provider_id"] == expected_provider, active +assert active["model"] == expected_model, active +assert provider["base_url"].rstrip("/") == expected_base, provider PY MARKER="AGENTTEAMS_QWENPAW_REAL_MODEL_E2E_$$_$(date +%s)" diff --git a/qwenpaw/tests/integration/test-update-runtime-config.sh b/qwenpaw/tests/integration/test-update-runtime-config.sh index 6b407fabe..7365ea4b8 100755 --- a/qwenpaw/tests/integration/test-update-runtime-config.sh +++ b/qwenpaw/tests/integration/test-update-runtime-config.sh @@ -61,39 +61,50 @@ qwenpaw_e2e_start_worker -e AGENTTEAMS_WORKER_GATEWAY_KEY=fake-gateway-key qwenpaw_e2e_wait_worker_http /api/version 240 qwenpaw_e2e_wait_worker_http /api/teamharness/health 240 -qwenpaw_e2e_exec /opt/venv/qwenpaw/bin/python - <<'PY' +initial_applied="false" +for _ in $(seq 1 45); do + if qwenpaw_e2e_exec /opt/venv/qwenpaw/bin/python - <<'PY' import json -import os -from pathlib import Path - -from qwenpaw.app.channels.access_control import get_access_control_store -from qwenpaw.config.config import load_agent_config -from qwenpaw.providers.provider_manager import ProviderManager - -workspace = Path(os.environ["QWENPAW_WORKING_DIR"]) / "workspaces" / "default" - -manager = ProviderManager.get_instance() -assert manager.active_model.provider_id == "agentteams-update-e2e", manager.active_model -assert manager.active_model.model == "qwen-fake-v1", manager.active_model -provider = manager.custom_providers["agentteams-update-e2e"] -assert provider.base_url == "https://dashscope.aliyuncs.com/compatible-mode/v1", provider.base_url -assert provider.api_key == "fake-gateway-key" - -mcp = json.loads((workspace / "config" / "mcporter.json").read_text(encoding="utf-8")) -assert mcp["mcpServers"]["docs"]["url"] == "https://gateway.example.com/mcp/docs-v1" -assert mcp["mcpServers"]["docs"]["headers"]["Authorization"] == "Bearer fake-gateway-key" -assert not (workspace / "mcporter-servers.json").exists() - -agent_config = load_agent_config("default") -assert agent_config.channels.matrix.access_control_group is True -assert agent_config.channels.matrix.access_control_dm is True - -acl = get_access_control_store(workspace).get_acl("matrix") +from urllib.request import urlopen + +def get(path): + with urlopen(f"http://127.0.0.1:8088{path}") as response: + return json.load(response) + +active = get("/api/models/active?scope=agent&agent_id=default")["active_llm"] +assert active["provider_id"] == "agentteams-update-e2e" and active["model"] == "qwen-fake-v1", active +provider = next(item for item in get("/api/models") if item["id"] == "agentteams-update-e2e") +assert provider["base_url"] == "https://dashscope.aliyuncs.com/compatible-mode/v1", provider +mcp = {item["key"]: item for item in get("/api/mcp")} +assert mcp.get("docs", {}).get("url") == "https://gateway.example.com/mcp/docs-v1", mcp +assert mcp.get("teamharness", {}).get("enabled") is True, mcp +assert mcp.get("workerflow", {}).get("enabled") is True, mcp +assert get("/api/mcp/policy/teamharness")["default_effect"] == "allow" +assert get("/api/mcp/policy/workerflow")["default_effect"] == "allow" +channel_types = get("/api/config/channels/types") +assert "matrix" in channel_types and "agentteams_matrix" in channel_types, channel_types +schema = get("/api/config/channels/schemas")["agentteams_matrix"] +assert schema["plugin_id"] == "agentteams-matrix-channel", schema +channel = get("/api/config/channels/agentteams_matrix") +assert channel["access_control_group"] is True +assert channel["access_control_dm"] is True +acl = get("/api/access-control/agentteams_matrix") assert "!team-v1:matrix.local" in acl["whitelist"], acl assert "@admin-v1:matrix.local" in acl["whitelist"], acl assert "!blocked-v1:matrix.local" in acl["blacklist"], acl assert "@blocked-v1:matrix.local" in acl["blacklist"], acl +skills = {item["name"]: item for item in get("/api/skills")} +assert skills.get("communication", {}).get("source") == "plugin:teamharness", skills +assert skills.get("worker-internal-workflow", {}).get("source") == "plugin:workerflow", skills PY + then + initial_applied="true" + break + fi + sleep 2 +done + +[ "${initial_applied}" = "true" ] || qwenpaw_e2e_fail "initial runtime.yaml was not applied" started_before="$(docker inspect -f '{{.State.StartedAt}}' "${QWENPAW_E2E_WORKER_CONTAINER}")" @@ -171,27 +182,21 @@ for _ in $(seq 1 45); do import json import os from pathlib import Path - -from qwenpaw.app.channels.access_control import get_access_control_store -from qwenpaw.config.config import load_agent_config -from qwenpaw.providers.provider_manager import ProviderManager +from urllib.request import urlopen workspace = Path(os.environ["QWENPAW_WORKING_DIR"]) / "workspaces" / "default" package_root = Path(os.environ["QWENPAW_WORKING_DIR"]) / "agent-packages" -manager = ProviderManager.get_instance() -assert manager.active_model.provider_id == "agentteams-update-e2e", manager.active_model -assert manager.active_model.model == "qwen-fake-v2", manager.active_model - -mcp = json.loads((workspace / "config" / "mcporter.json").read_text(encoding="utf-8")) -assert mcp["mcpServers"]["docs"]["url"] == "https://gateway.example.com/mcp/docs-v2" -assert not (workspace / "mcporter-servers.json").exists() - -agent_config = load_agent_config("default") -assert agent_config.mcp.clients["package-docs"].url == "https://package.example.com/mcp/docs" -assert not (workspace / "mcp.json").exists() +def get(path): + with urlopen(f"http://127.0.0.1:8088{path}") as response: + return json.load(response) -acl = get_access_control_store(workspace).get_acl("matrix") +active = get("/api/models/active?scope=agent&agent_id=default")["active_llm"] +assert active["provider_id"] == "agentteams-update-e2e" and active["model"] == "qwen-fake-v2", active +mcp = {item["key"]: item for item in get("/api/mcp")} +assert mcp.get("docs", {}).get("url") == "https://gateway.example.com/mcp/docs-v2", mcp +assert mcp.get("package-docs", {}).get("url") == "https://package.example.com/mcp/docs", mcp +acl = get("/api/access-control/agentteams_matrix") assert "!team-v2:matrix.local" in acl["whitelist"], acl assert "@admin-v2:matrix.local" in acl["whitelist"], acl assert "!blocked-v2:matrix.local" in acl["blacklist"], acl @@ -199,6 +204,8 @@ assert "@blocked-v2:matrix.local" in acl["blacklist"], acl assert "Package Generation 2" in (workspace / "AGENTS.md").read_text(encoding="utf-8") assert (workspace / "skills" / "hot-skill" / "SKILL.md").is_file() +skills = {item["name"]: item for item in get("/api/skills")} +assert skills.get("hot-skill", {}).get("enabled") is True, skills identity = (package_root / "current.identity").read_text(encoding="utf-8").splitlines() assert identity[:4] == [ "file:///tmp/qwenpaw-agent-package-v2", diff --git a/qwenpaw/tests/test_api.py b/qwenpaw/tests/test_api.py new file mode 100644 index 000000000..92dfb6f69 --- /dev/null +++ b/qwenpaw/tests/test_api.py @@ -0,0 +1,273 @@ +import json +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from threading import Thread + +import pytest + +from qwenpaw_worker.api import QwenPawApiClient, QwenPawApiError + + +class _ApiHandler(BaseHTTPRequestHandler): + channel = {"enabled": False, "client_secret": "existing-secret"} + channel_readback_override = None + agents = [ + {"id": "default", "enabled": True}, + {"id": "QwenPaw_QA_Agent_0.2", "enabled": True}, + ] + acl = { + "whitelist": {"@old:example.com": {"remark": "keep", "username": "old"}}, + "blacklist": {}, + } + mcp_policy = { + "default_effect": "deny", + "client_overrides": [], + "tool_defaults": [], + "tool_overrides": [], + "unmanaged_rules_count": 0, + } + mcp = {} + toggle_conflicts = 0 + + def log_message(self, _format, *_args): + return + + def _payload(self): + length = int(self.headers.get("Content-Length", "0")) + return json.loads(self.rfile.read(length) or b"{}") + + def _reply(self, status, payload): + body = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if self.path == "/api/version": + self._reply(200, {"version": "2.0.1"}) + return + if self.path == "/api/config/channels/agentteams_matrix": + self._reply( + 200, + type(self).channel_readback_override or type(self).channel, + ) + return + if self.path == "/api/access-control/agentteams_matrix": + self._reply(200, type(self).acl) + return + if self.path == "/api/mcp/policy/teamharness": + self._reply(200, type(self).mcp_policy) + return + if self.path == "/api/mcp": + self._reply(200, list(type(self).mcp.values())) + return + if self.path.startswith("/api/mcp/"): + key = self.path.removeprefix("/api/mcp/") + if key in type(self).mcp: + self._reply(200, type(self).mcp[key]) + else: + self._reply(404, {"detail": "missing"}) + return + if self.path == "/api/agents": + self._reply(200, {"agents": type(self).agents}) + return + self._reply(404, {"detail": "missing"}) + + def do_PUT(self): + payload = self._payload() + if self.path == "/api/config/channels/agentteams_matrix": + type(self).channel = payload + self._reply(200, payload) + return + if self.path == "/api/mcp/policy/teamharness": + type(self).mcp_policy = {**payload, "unmanaged_rules_count": 0} + self._reply(200, type(self).mcp_policy) + return + if self.path.startswith("/api/mcp/"): + key = self.path.removeprefix("/api/mcp/") + type(self).mcp[key] = {**type(self).mcp[key], **payload, "key": key} + self._reply(200, type(self).mcp[key]) + return + self._reply(404, {"detail": "missing"}) + + def do_POST(self): + payload = self._payload() + actions = { + "/api/access-control/whitelist/add": ("whitelist", True), + "/api/access-control/whitelist/remove": ("whitelist", False), + "/api/access-control/blacklist/add": ("blacklist", True), + "/api/access-control/blacklist/remove": ("blacklist", False), + } + if self.path in actions: + list_name, adding = actions[self.path] + entries = type(self).acl[list_name] + for entry in payload["entries"]: + user_id = entry["user_id"] + if adding: + entries[user_id] = { + "remark": entry.get("remark", ""), + "username": entry.get("username", ""), + } + else: + entries.pop(user_id, None) + self._reply(200, {"success": True}) + return + if self.path == "/api/mcp": + key = payload["client_key"] + type(self).mcp[key] = {"key": key, **payload["client"]} + self._reply(200, type(self).mcp[key]) + return + self._reply(404, {"detail": "missing"}) + + def do_DELETE(self): + if self.path.startswith("/api/mcp/"): + key = self.path.removeprefix("/api/mcp/") + type(self).mcp.pop(key, None) + self._reply(200, {"success": True}) + return + self._reply(404, {"detail": "missing"}) + + def do_PATCH(self): + payload = self._payload() + if self.path == "/api/agents/QwenPaw_QA_Agent_0.2/toggle": + if type(self).toggle_conflicts: + type(self).toggle_conflicts -= 1 + self._reply(409, {"detail": "agent is still starting"}) + return + for agent in type(self).agents: + if agent["id"] == "QwenPaw_QA_Agent_0.2": + agent["enabled"] = payload["enabled"] + self._reply(200, {"success": True, "enabled": payload["enabled"]}) + return + self._reply(404, {"detail": "missing"}) + + +@pytest.fixture() +def api_url(): + _ApiHandler.channel = {"enabled": False, "client_secret": "existing-secret"} + _ApiHandler.channel_readback_override = None + _ApiHandler.agents = [ + {"id": "default", "enabled": True}, + {"id": "QwenPaw_QA_Agent_0.2", "enabled": True}, + ] + _ApiHandler.acl = { + "whitelist": {"@old:example.com": {"remark": "keep", "username": "old"}}, + "blacklist": {}, + } + _ApiHandler.mcp_policy = { + "default_effect": "deny", + "client_overrides": [], + "tool_defaults": [], + "tool_overrides": [], + "unmanaged_rules_count": 0, + } + _ApiHandler.mcp = {} + _ApiHandler.toggle_conflicts = 0 + server = ThreadingHTTPServer(("127.0.0.1", 0), _ApiHandler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}" + finally: + server.shutdown() + thread.join() + + +def test_put_channel_preserves_empty_secret_and_reads_back(api_url): + client = QwenPawApiClient(api_url) + + result = client.put_channel( + "agentteams_matrix", + {"enabled": True, "client_secret": ""}, + secret_fields={"client_secret"}, + ) + + assert result == {"enabled": True, "client_secret": "existing-secret"} + + +def test_put_channel_rejects_readback_mismatch(api_url): + client = QwenPawApiClient(api_url) + _ApiHandler.channel_readback_override = {"enabled": False} + + with pytest.raises(QwenPawApiError, match="readback mismatch: enabled"): + client.put_channel("agentteams_matrix", {"enabled": True}) + + +def test_require_version_rejects_unexpected_qwenpaw(api_url): + client = QwenPawApiClient(api_url) + + with pytest.raises(QwenPawApiError, match="expected QwenPaw 2.0.0"): + client.require_version("2.0.0") + + +def test_http_error_and_timeout_are_safe(api_url, monkeypatch): + client = QwenPawApiClient(api_url) + + with pytest.raises(QwenPawApiError, match="HTTP 404"): + client.get_acl("missing") + + def timeout(*_args, **_kwargs): + raise TimeoutError("sensitive upstream detail") + + monkeypatch.setattr("urllib.request.urlopen", timeout) + with pytest.raises(QwenPawApiError, match="unavailable: TimeoutError") as exc: + client.get_version() + assert "sensitive upstream detail" not in str(exc.value) + + +def test_acl_reconcile_parses_structured_entries_and_is_channel_scoped(api_url): + client = QwenPawApiClient(api_url) + + result = client.reconcile_acl( + "agentteams_matrix", + ["@new:example.com"], + ["@blocked:example.com"], + ) + + assert result["whitelist"] == { + "@new:example.com": {"remark": "", "username": ""}, + } + assert set(result["blacklist"]) == {"@blocked:example.com"} + + +def test_put_mcp_policy_uses_public_api_and_reads_back(api_url): + client = QwenPawApiClient(api_url) + + result = client.put_mcp_policy( + "teamharness", + { + "default_effect": "allow", + "client_overrides": [], + "tool_defaults": [], + "tool_overrides": [], + }, + ) + + assert result["default_effect"] == "allow" + + +def test_mcp_create_update_delete_each_reads_back(api_url): + client = QwenPawApiClient(api_url) + + created = client.create_mcp( + "owned", + {"name": "owned", "enabled": True, "transport": "stdio"}, + ) + assert created["enabled"] is True + updated = client.update_mcp("owned", {"enabled": False}) + assert updated["enabled"] is False + client.delete_mcp("owned") + assert client.list_mcp() == [] + + +def test_disable_agent_retries_startup_conflict(api_url): + client = QwenPawApiClient(api_url) + _ApiHandler.toggle_conflicts = 2 + + assert client.disable_agent_if_present( + "QwenPaw_QA_Agent_0.2", + retries=2, + retry_delay=0, + ) is True + assert _ApiHandler.agents[1]["enabled"] is False diff --git a/qwenpaw/tests/test_package_update.py b/qwenpaw/tests/test_package_update.py index bbdc58749..cc66cfbe3 100644 --- a/qwenpaw/tests/test_package_update.py +++ b/qwenpaw/tests/test_package_update.py @@ -167,7 +167,7 @@ def test_runtime_updater_applies_inline_prompt_config_to_workspace(tmp_path: Pat runtime_config_path=tmp_path / "runtime.yaml", agent_role="worker", ) - updater = RuntimeUpdater(config, model_runtime_sync=lambda _config: None) + updater = RuntimeUpdater(config) updater.apply_once(runtime_config=_runtime_config_with_inline_config(tmp_path)) @@ -307,7 +307,7 @@ def test_agent_package_manager_copies_package_materials_to_default_workspace(tmp assert (workspace_dir / "config" / "mcporter.json").read_text(encoding="utf-8") == '{"runtime":true}\n' -def test_agent_package_manager_embeds_root_mcp_json_into_qwenpaw_agent_config( +def _legacy_agent_package_manager_embeds_root_mcp_json_into_qwenpaw_agent_config( tmp_path: Path, monkeypatch, ) -> None: @@ -364,7 +364,7 @@ def __init__(self, **kwargs) -> None: assert (workspace_dir / "agent.json").read_text(encoding="utf-8") == '{"existing":true}\n' -def test_agent_package_mcp_json_expands_agent_workspace_placeholders( +def _legacy_agent_package_mcp_json_expands_agent_workspace_placeholders( tmp_path: Path, monkeypatch, ) -> None: @@ -423,7 +423,7 @@ def __init__(self, **kwargs) -> None: assert client.env["CUSTOM_DIR"] == str(workspace_dir / "custom") -def test_agent_package_manager_accepts_mcporter_style_mcp_json( +def _legacy_agent_package_manager_accepts_mcporter_style_mcp_json( tmp_path: Path, monkeypatch, ) -> None: @@ -472,7 +472,7 @@ def test_agent_package_manager_accepts_mcporter_style_mcp_json( } -def test_agent_package_manager_accepts_line_comments_in_mcp_json( +def _legacy_agent_package_manager_accepts_line_comments_in_mcp_json( tmp_path: Path, monkeypatch, ) -> None: @@ -505,7 +505,7 @@ def test_agent_package_manager_accepts_line_comments_in_mcp_json( assert clients["remote-docs"].transport == "http" -def test_agent_package_manager_accepts_commented_mcp_json_shapes( +def _legacy_agent_package_manager_accepts_commented_mcp_json_shapes( tmp_path: Path, monkeypatch, ) -> None: @@ -585,7 +585,22 @@ def test_agent_package_mcp_json_comment_stripper_preserves_string_slashes_and_es assert docs["args"] == ["--literal=//not-comment", 'quoted " // still string'] -def test_agent_package_manager_reads_legacy_mcp_json_shapes( +def test_agent_package_mcp_normalizes_http_transport_for_qwenpaw_api(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + manager = AgentPackageManager(tmp_path / "packages", workspace_dir=workspace) + package_path = _package( + tmp_path, + "transport", + mcp_servers={"docs": {"url": "https://docs.example.com/mcp", "transport": "http"}}, + ) + + installed = manager.apply(_runtime_config(tmp_path, package_path, "transport")) + + assert manager.package_mcp_clients(installed)["docs"]["transport"] == "streamable_http" + + +def _legacy_agent_package_manager_reads_legacy_mcp_json_shapes( tmp_path: Path, monkeypatch, ) -> None: @@ -610,7 +625,7 @@ def test_agent_package_manager_reads_legacy_mcp_json_shapes( assert client.url == mcp_json.get("docs", {"url": f"https://{version}.example.com/mcp"})["url"] -def test_agent_package_mcp_json_does_not_overwrite_mcporter_config( +def _legacy_agent_package_mcp_json_does_not_overwrite_mcporter_config( tmp_path: Path, monkeypatch, ) -> None: @@ -1006,7 +1021,7 @@ def test_agent_package_manager_creates_empty_prompt_files_when_package_has_none( assert (workspace_dir / "SOUL.md").read_text(encoding="utf-8") == "" -def test_agent_package_manager_reconciles_qwenpaw_workspace_skill_manifest( +def _legacy_agent_package_manager_reconciles_qwenpaw_workspace_skill_manifest( tmp_path: Path, monkeypatch, ) -> None: @@ -1028,7 +1043,7 @@ def test_agent_package_manager_reconciles_qwenpaw_workspace_skill_manifest( assert calls == [workspace_dir] -def test_agent_package_manager_enables_package_skills_in_workspace_manifest( +def _legacy_agent_package_manager_enables_package_skills_in_workspace_manifest( tmp_path: Path, monkeypatch, ) -> None: @@ -1063,7 +1078,7 @@ def reconcile_workspace_manifest(path): assert manifest["skills"]["code-review"]["enabled"] is True -def test_agent_package_manager_rolls_back_workspace_and_current_when_workspace_apply_fails( +def _legacy_agent_package_manager_rolls_back_workspace_and_current_when_workspace_apply_fails( tmp_path: Path, monkeypatch, ) -> None: diff --git a/qwenpaw/tests/test_qwenpaw2_runtime_contract.py b/qwenpaw/tests/test_qwenpaw2_runtime_contract.py deleted file mode 100644 index c8a06d14f..000000000 --- a/qwenpaw/tests/test_qwenpaw2_runtime_contract.py +++ /dev/null @@ -1,306 +0,0 @@ -from __future__ import annotations - -import os -from pathlib import Path -import subprocess -import sys -import textwrap - - -ROOT = Path(__file__).resolve().parents[1] - - -def _run_qwenpaw2(tmp_path: Path, script: str) -> None: - home = tmp_path / "home" - working_dir = home / ".qwenpaw" - env = os.environ.copy() - env.update( - { - "HOME": str(home), - "PYTHONPATH": str(ROOT / "src"), - "QWENPAW_WORKING_DIR": str(working_dir), - "QWENPAW_SECRET_DIR": str(home / ".qwenpaw.secret"), - "QWENPAW_RUNNING_IN_CONTAINER": "true", - } - ) - result = subprocess.run( - [sys.executable, "-c", textwrap.dedent(script), str(tmp_path)], - check=False, - env=env, - capture_output=True, - text=True, - ) - assert result.returncode == 0, ( - f"QwenPaw 2 contract subprocess failed\n" - f"stdout:\n{result.stdout}\n" - f"stderr:\n{result.stderr}" - ) - - -def test_qwenpaw2_applies_real_model_provider_from_runtime_contract( - tmp_path: Path, -) -> None: - _run_qwenpaw2( - tmp_path, - """ - import os - from pathlib import Path - import sys - - from qwenpaw.providers.provider_manager import ProviderManager - from qwenpaw_worker.config import WorkerConfig - from qwenpaw_worker.update import MemberRuntimeConfig, RuntimeUpdater - - root = Path(sys.argv[1]) - os.environ["BAILIAN_API_KEY"] = "test-bailian-key" - config = WorkerConfig( - worker_name="worker-a", - fs_endpoint="unused", - fs_access_key="unused", - fs_secret_key="unused", - install_dir=root / "agents", - ) - - class NoopPackageManager: - def apply(self, _config): - return None - - RuntimeUpdater( - config=config, - package_manager=NoopPackageManager(), - ).apply_once( - runtime_config=MemberRuntimeConfig( - path=config.runtime_config_path, - raw={ - "metadata": {"generation": "1"}, - "member": {"runtime": "qwenpaw"}, - "desired": { - "model": { - "providerId": "bailian", - "providerName": "Alibaba Cloud Model Studio", - "model": "qwen3.6-plus", - "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1", - "apiKeyEnv": "BAILIAN_API_KEY", - }, - }, - }, - ), - reapply_adapter=False, - ) - - manager = ProviderManager.get_instance() - provider = manager.custom_providers["bailian"] - assert manager.active_model.provider_id == "bailian" - assert manager.active_model.model == "qwen3.6-plus" - assert provider.base_url == "https://dashscope.aliyuncs.com/compatible-mode/v1" - assert provider.api_key == "test-bailian-key" - assert provider.chat_model == "OpenAIChatModel" - """, - ) - - -def test_qwenpaw2_loads_runtime_mcp_and_agent_package_skills( - tmp_path: Path, -) -> None: - _run_qwenpaw2( - tmp_path, - """ - import json - import os - from pathlib import Path - import sys - - from qwenpaw.agents.skill_system.registry import resolve_effective_skills - from qwenpaw.config.config import load_agent_config - from qwenpaw_worker.config import WorkerConfig - from qwenpaw_worker.update import MemberRuntimeConfig, RuntimeUpdater - - root = Path(sys.argv[1]) - package = root / "agent-package" - skill = package / "skills" / "hot-skill" - skill.mkdir(parents=True) - (skill / "SKILL.md").write_text( - "---\\nname: hot-skill\\ndescription: runtime contract\\n---\\n# Hot Skill\\n", - encoding="utf-8", - ) - (package / "mcp.json").write_text( - json.dumps( - { - "mcpServers": { - "package-docs": { - "url": "https://package.example.com/mcp", - "transport": "http", - }, - }, - }, - ), - encoding="utf-8", - ) - - os.environ["AGENTTEAMS_WORKER_GATEWAY_KEY"] = "test-gateway-key" - config = WorkerConfig( - worker_name="worker-a", - fs_endpoint="unused", - fs_access_key="unused", - fs_secret_key="unused", - install_dir=root / "agents", - ) - RuntimeUpdater(config=config).apply_once( - runtime_config=MemberRuntimeConfig( - path=config.runtime_config_path, - raw={ - "metadata": {"generation": "1"}, - "member": {"runtime": "qwenpaw"}, - "credentials": { - "gatewayKeyEnv": "AGENTTEAMS_WORKER_GATEWAY_KEY", - }, - "desired": { - "agentPackage": { - "ref": package.as_uri(), - "name": "contract-package", - "version": "1.0.0", - "digest": "sha256:contract", - }, - "mcpServers": { - "runtime-docs": { - "url": "https://gateway.example.com/mcp/docs", - "transport": "http", - }, - }, - }, - }, - ), - reapply_adapter=False, - ) - - workspace = config.default_workspace_dir - mcporter = json.loads( - (workspace / "config" / "mcporter.json").read_text(encoding="utf-8"), - ) - assert mcporter["mcpServers"]["runtime-docs"] == { - "url": "https://gateway.example.com/mcp/docs", - "transport": "http", - "headers": {"Authorization": "Bearer test-gateway-key"}, - } - - package_client = load_agent_config("default").mcp.clients["package-docs"] - assert package_client.url == "https://package.example.com/mcp" - assert package_client.transport == "streamable_http" - assert resolve_effective_skills(workspace, "matrix") == ["hot-skill"] - """, - ) - - -def test_qwenpaw2_configures_matrix_overlay_and_sends_text( - tmp_path: Path, -) -> None: - _run_qwenpaw2( - tmp_path, - """ - import asyncio - import importlib.util - import os - from pathlib import Path - import sys - import types - - from qwenpaw.config.config import load_agent_config - from qwenpaw_worker.config import WorkerConfig - from qwenpaw_worker.update import MemberRuntimeConfig, RuntimeUpdater - - root = Path(sys.argv[1]) - os.environ["AGENTTEAMS_MATRIX_URL"] = "http://matrix.example.com:6167" - os.environ["AGENTTEAMS_WORKER_MATRIX_TOKEN"] = "test-matrix-token" - os.environ["AGENTTEAMS_MATRIX_E2EE"] = "false" - config = WorkerConfig( - worker_name="worker-a", - fs_endpoint="unused", - fs_access_key="unused", - fs_secret_key="unused", - install_dir=root / "agents", - ) - - class NoopPackageManager: - def apply(self, _config): - return None - - RuntimeUpdater( - config=config, - package_manager=NoopPackageManager(), - ).apply_once( - runtime_config=MemberRuntimeConfig( - path=config.runtime_config_path, - raw={ - "metadata": {"generation": "1"}, - "team": {"teamRoomId": "!team:matrix.local"}, - "member": { - "runtime": "qwenpaw", - "matrixUserId": "@worker-a:matrix.local", - }, - "credentials": { - "matrixTokenEnv": "AGENTTEAMS_WORKER_MATRIX_TOKEN", - }, - }, - ), - reapply_adapter=False, - ) - - matrix_config = load_agent_config("default").channels.matrix - assert matrix_config.enabled is True - assert matrix_config.homeserver == "http://matrix.example.com:6167" - assert matrix_config.user_id == "@worker-a:matrix.local" - assert matrix_config.access_token == "test-matrix-token" - assert matrix_config.password == "" - assert matrix_config.encryption is False - assert matrix_config.group_disabled is False - assert matrix_config.dm_disabled is False - assert matrix_config.groups["!team:matrix.local"]["requireMention"] is True - - overlay_path = Path( - os.environ["PYTHONPATH"], - ) / "matrix" / "channel.py" - module_name = "qwenpaw.app.channels.matrix.agentteams_contract_channel" - spec = importlib.util.spec_from_file_location(module_name, overlay_path) - assert spec is not None and spec.loader is not None - overlay = importlib.util.module_from_spec(spec) - sys.modules[module_name] = overlay - spec.loader.exec_module(overlay) - - channel = overlay.MatrixChannel.from_config( - lambda *_args, **_kwargs: None, - matrix_config, - workspace_dir=config.default_workspace_dir, - ) - assert channel.homeserver == "http://matrix.example.com:6167" - assert channel.matrix_user_id == "@worker-a:matrix.local" - assert channel.get_debounce_key( - {"meta": {"room_id": "!team:matrix.local"}}, - ) == "matrix:!team:matrix.local" - - calls = [] - - class FakeClient: - async def room_send(self, *args, **kwargs): - calls.append((args, kwargs)) - return types.SimpleNamespace(event_id="$sent") - - async def room_typing(self, *_args, **_kwargs): - return None - - channel._client = FakeClient() - asyncio.run( - channel.send( - "!team:matrix.local", - "hello from QwenPaw 2", - {"room_id": "!team:matrix.local"}, - ), - ) - assert len(calls) == 1 - args, kwargs = calls[0] - assert args[0] == "!team:matrix.local" - assert args[1] == "m.room.message" - assert args[2]["msgtype"] == "m.text" - assert args[2]["body"] == "hello from QwenPaw 2" - assert kwargs["tx_id"] - """, - ) diff --git a/qwenpaw/tests/test_runtime_dependencies.py b/qwenpaw/tests/test_runtime_dependencies.py index b0b873257..4bffa593b 100644 --- a/qwenpaw/tests/test_runtime_dependencies.py +++ b/qwenpaw/tests/test_runtime_dependencies.py @@ -11,10 +11,32 @@ def test_qwenpaw_acp_api_is_compatible() -> None: assert SetSessionModelResponse is not None -def test_worker_and_image_target_qwenpaw_2_post3() -> None: +def test_worker_and_image_target_qwenpaw_2_0_1() -> None: project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") assert project["project"]["requires-python"] == ">=3.11,<3.14" - assert "qwenpaw==2.0.0.post3" in project["project"]["dependencies"] - assert "ARG QWENPAW_PIP_SPEC=qwenpaw==2.0.0.post3" in dockerfile + assert "qwenpaw==2.0.1" in project["project"]["dependencies"] + assert "agent-client-protocol>=0.9.0,<0.11.0" in project["project"]["dependencies"] + assert "ARG QWENPAW_PIP_SPEC=qwenpaw==2.0.1" in dockerfile + + +def test_runtime_uses_public_qwenpaw_extension_points_only() -> None: + sources = "\n".join( + path.read_text(encoding="utf-8") + for path in ( + ROOT / "src" / "qwenpaw_worker" / "worker.py", + ROOT / "src" / "qwenpaw_worker" / "update.py", + ROOT.parent / "plugins" / "teamharness" / "adapters" / "qwenpaw" / "plugin.py", + ROOT.parent / "plugins" / "workerflow" / "adapters" / "qwenpaw" / "plugin.py", + ) + ) + for forbidden in ( + "QwenPawAgent._acting", + "legacy_mcp_client_to_driver", + "SkillPoolService", + "save_agent_config", + "ProviderManager", + "get_access_control_store", + ): + assert forbidden not in sources diff --git a/qwenpaw/tests/test_sync.py b/qwenpaw/tests/test_sync.py index af4148add..3134236bf 100644 --- a/qwenpaw/tests/test_sync.py +++ b/qwenpaw/tests/test_sync.py @@ -1,8 +1,11 @@ +import asyncio import os import subprocess from pathlib import Path -from qwenpaw_worker.sync import FileSync, push_local +import pytest + +from qwenpaw_worker.sync import FileSync, push_local, push_loop def _sync(tmp_path: Path) -> FileSync: @@ -290,3 +293,31 @@ def fake_mc(*args, **_kwargs): assert push_local(sync, since=0) == ["large.zip"] assert uploads == ["agentteams/agentteams-storage/agents/worker-a/large.zip"] + + +@pytest.mark.anyio +async def test_push_loop_starts_from_current_time_instead_of_full_scan( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + sync = _sync(tmp_path) + since_values: list[float] = [] + sleeps = 0 + + async def fake_sleep(_seconds: float) -> None: + nonlocal sleeps + sleeps += 1 + if sleeps > 1: + raise asyncio.CancelledError + + async def fake_to_thread(_function, _sync, since): + since_values.append(since) + return [] + + monkeypatch.setattr("qwenpaw_worker.sync.time.time", lambda: 123.0) + monkeypatch.setattr("qwenpaw_worker.sync.asyncio.sleep", fake_sleep) + monkeypatch.setattr("qwenpaw_worker.sync.asyncio.to_thread", fake_to_thread) + + await push_loop(sync, check_interval=0) + + assert since_values == [123.0] diff --git a/qwenpaw/tests/test_update.py b/qwenpaw/tests/test_update.py index 353a00746..8be7b0756 100644 --- a/qwenpaw/tests/test_update.py +++ b/qwenpaw/tests/test_update.py @@ -1,5 +1,4 @@ import asyncio -import io import json import logging import os @@ -7,14 +6,12 @@ import sys import tarfile import types -import urllib.error import pytest from qwenpaw_worker.config import WorkerConfig from qwenpaw_worker.update import ( MemberRuntimeConfig, - QwenPawModelRuntimeSync, RuntimeUpdater, TEAMS_INTERNAL_CONTROL_MARKER, ) @@ -44,6 +41,175 @@ def _config(tmp_path: Path) -> WorkerConfig: ) +class _FakeQwenPawApi: + def __init__(self): + self.channels = { + "agentteams_matrix": {}, + "dingtalk": {}, + } + self.acls = {"agentteams_matrix": {"whitelist": {}, "blacklist": {}, "pending": []}} + self.mcp = {} + self.active_model = None + self.enabled_skills = [] + + def get_channel(self, channel): + return dict(self.channels.get(channel, {})) + + def put_channel(self, channel, desired, *, secret_fields=()): + current = self.get_channel(channel) + payload = dict(desired) + for field in secret_fields: + if not payload.get(field) and current.get(field): + payload[field] = current[field] + self.channels[channel] = payload + return dict(payload) + + def reconcile_acl(self, channel, whitelist, blacklist): + payload = { + "whitelist": {item: {} for item in whitelist}, + "blacklist": {item: {} for item in blacklist}, + "pending": [], + } + self.acls[channel] = payload + return payload + + def list_mcp(self): + return list(self.mcp.values()) + + def create_mcp(self, key, payload): + self.mcp[key] = {"key": key, **payload} + return self.mcp[key] + + def update_mcp(self, key, payload): + self.mcp[key] = {"key": key, **payload} + return self.mcp[key] + + def delete_mcp(self, key): + self.mcp.pop(key, None) + + def configure_active_model(self, provider_id, model, **kwargs): + self.active_model = {"provider_id": provider_id, "model": model, **kwargs} + return {"active_llm": self.active_model} + + def refresh_and_enable_skills(self, skill_names): + self.enabled_skills = list(skill_names) + return {"results": {name: {"success": True} for name in self.enabled_skills}} + + +def _runtime_updater(*args, **kwargs): + kwargs.setdefault("api_client", _FakeQwenPawApi()) + return RuntimeUpdater(*args, **kwargs) + + +def test_runtime_updater_reconciles_model_mcp_matrix_channel_and_acl_via_api( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AGENTTEAMS_MATRIX_URL", "http://matrix.example.com") + monkeypatch.setenv("AGENTTEAMS_WORKER_MATRIX_TOKEN", "matrix-token") + monkeypatch.setenv("AGENTTEAMS_WORKER_GATEWAY_KEY", "gateway-secret") + updater = _runtime_updater( + config=_config(tmp_path), + package_manager=_NoopPackageManager(), + ) + + updater.apply_once( + runtime_config=MemberRuntimeConfig( + path=updater.config.runtime_config_path, + raw={ + "metadata": {"generation": "1"}, + "team": {"teamRoomId": "!team:matrix.local"}, + "member": { + "runtime": "qwenpaw", + "matrixUserId": "@worker-a:matrix.local", + }, + "credentials": { + "matrixTokenEnv": "AGENTTEAMS_WORKER_MATRIX_TOKEN", + "gatewayKeyEnv": "AGENTTEAMS_WORKER_GATEWAY_KEY", + }, + "desired": { + "model": { + "providerId": "agentteams-gateway", + "model": "qwen-plus", + "gatewayUrl": "https://gateway.example.com", + }, + "mcpServers": [ + {"name": "docs", "url": "https://gateway.example.com/mcp"}, + ], + "channelPolicy": { + "groupAllowExtra": ["@human:matrix.local"], + }, + }, + }, + ), + ) + + api = updater.api_client + assert api.active_model["provider_id"] == "agentteams-gateway" + assert api.mcp["docs"]["transport"] == "streamable_http" + assert api.channels["agentteams_matrix"]["access_token"] == "matrix-token" + assert api.channels["agentteams_matrix"]["groups"]["!team:matrix.local"]["requireMention"] is True + assert "@human:matrix.local" in api.acls["agentteams_matrix"]["whitelist"] + assert not (updater.config.default_workspace_dir / "agent.json").exists() + assert not (updater.config.default_workspace_dir / "config" / "mcporter.json").exists() + assert not (updater.config.default_workspace_dir / "access_control.json").exists() + + +def test_runtime_updater_maps_dingtalk_visibility_and_preserves_empty_secret( + tmp_path: Path, +) -> None: + updater = _runtime_updater( + config=_config(tmp_path), + package_manager=_NoopPackageManager(), + ) + updater.api_client.channels["dingtalk"] = { + "enabled": True, + "client_secret": "existing-secret", + } + + updater.apply_once( + runtime_config=MemberRuntimeConfig( + path=updater.config.runtime_config_path, + raw={ + "metadata": {"generation": "1"}, + "member": {"runtime": "qwenpaw"}, + "desired": { + "channels": { + "dingtalk": { + "enabled": True, + "client_id": "client-id", + "client_secret": "", + "robot_code": "robot", + "filter_thinking": True, + "filter_tool_messages": False, + }, + }, + }, + }, + ), + ) + + actual = updater.api_client.channels["dingtalk"] + assert actual["client_secret"] == "existing-secret" + assert actual["show_thinking"] is False + assert actual["show_tool_calls"] is True + assert actual["show_tool_results"] is True + + +def test_runtime_updater_preserves_unmanaged_mcp_clients(tmp_path: Path) -> None: + updater = _runtime_updater(config=_config(tmp_path), package_manager=_NoopPackageManager()) + updater.api_client.mcp["third-party"] = {"key": "third-party", "name": "third-party"} + + updater.apply_once( + runtime_config=MemberRuntimeConfig( + path=updater.config.runtime_config_path, + raw={"metadata": {"generation": "1"}, "member": {"runtime": "qwenpaw"}}, + ), + ) + + assert "third-party" in updater.api_client.mcp + + def _agent_package(tmp_path: Path, version: str, *, include_teams: bool = False) -> Path: source_dir = tmp_path / f"package-src-{version}" config_dir = source_dir / "config" @@ -68,7 +234,7 @@ def apply(self, runtime_config: MemberRuntimeConfig): applied.append(runtime_config.generation) return tmp_path / "packages" / runtime_config.generation - updater = RuntimeUpdater( + updater = _runtime_updater( config=config, adapter_apply=lambda: adapter_calls.append("adapter"), package_manager=FakePackageManager(), @@ -119,7 +285,7 @@ def apply(self, runtime_config: MemberRuntimeConfig): """, encoding="utf-8", ) - updater = RuntimeUpdater( + updater = _runtime_updater( config=config, adapter_apply=lambda: adapter_calls.append("adapter"), package_manager=FakePackageManager(), @@ -144,7 +310,7 @@ def test_runtime_updater_logs_safe_apply_summary_without_sensitive_values( config = _config(tmp_path) adapter_calls: list[str] = [] caplog.set_level(logging.INFO, logger="qwenpaw_worker.update") - updater = RuntimeUpdater( + updater = _runtime_updater( config=config, adapter_apply=lambda: adapter_calls.append("adapter"), package_manager=_NoopPackageManager(), @@ -219,14 +385,14 @@ def test_runtime_config_identity_distinguishes_empty_list_and_empty_object(tmp_p assert first.desired_identity != second.desired_identity -def test_runtime_updater_does_not_reapply_adapter_for_mcp_only_change( +def _legacy_test_runtime_updater_does_not_reapply_adapter_for_mcp_only_change( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: config = _config(tmp_path) monkeypatch.setenv("AGENTTEAMS_WORKER_GATEWAY_KEY", "gateway-secret") adapter_calls: list[str] = [] - updater = RuntimeUpdater( + updater = _runtime_updater( config=config, adapter_apply=lambda: adapter_calls.append("adapter"), package_manager=_NoopPackageManager(), @@ -277,7 +443,7 @@ def test_runtime_updater_reapplies_adapter_when_credentials_change_with_mcp( ) -> None: config = _config(tmp_path) adapter_calls: list[str] = [] - updater = RuntimeUpdater( + updater = _runtime_updater( config=config, adapter_apply=lambda: adapter_calls.append("adapter"), package_manager=_NoopPackageManager(), @@ -336,7 +502,7 @@ def test_runtime_updater_does_not_reapply_adapter_for_dingtalk_channel_change( config = _config(tmp_path) monkeypatch.setenv("AGENTTEAMS_WORKER_GATEWAY_KEY", "gateway-secret") adapter_calls: list[str] = [] - updater = RuntimeUpdater( + updater = _runtime_updater( config=config, adapter_apply=lambda: adapter_calls.append("adapter"), package_manager=_NoopPackageManager(), @@ -383,7 +549,7 @@ def test_runtime_updater_does_not_reapply_adapter_for_mcp_and_channel_change( config = _config(tmp_path) monkeypatch.setenv("AGENTTEAMS_WORKER_GATEWAY_KEY", "gateway-secret") adapter_calls: list[str] = [] - updater = RuntimeUpdater( + updater = _runtime_updater( config=config, adapter_apply=lambda: adapter_calls.append("adapter"), package_manager=_NoopPackageManager(), @@ -432,7 +598,7 @@ def test_runtime_updater_applies_member_role_to_config_and_env( monkeypatch.delenv("AGENTTEAMS_WORKER_ROLE", raising=False) monkeypatch.delenv("AGENTTEAMS_AGENT_ROLE", raising=False) config = _config(tmp_path) - updater = RuntimeUpdater(config=config, package_manager=_NoopPackageManager()) + updater = _runtime_updater(config=config, package_manager=_NoopPackageManager()) updater.apply_once( runtime_config=MemberRuntimeConfig( @@ -462,7 +628,7 @@ def test_runtime_updater_refreshes_teams_md_without_secrets(tmp_path: Path) -> N """, encoding="utf-8", ) - updater = RuntimeUpdater(config=config, package_manager=_NoopPackageManager()) + updater = _runtime_updater(config=config, package_manager=_NoopPackageManager()) updater.apply_once( runtime_config=MemberRuntimeConfig( @@ -542,7 +708,7 @@ def render_team_context(runtime_config: MemberRuntimeConfig) -> str: """ - updater = RuntimeUpdater( + updater = _runtime_updater( config=config, adapter_apply=lambda: adapter_calls.append("adapter"), package_manager=_NoopPackageManager(), @@ -590,7 +756,7 @@ def render_team_context(runtime_config: MemberRuntimeConfig) -> str: """ - updater = RuntimeUpdater( + updater = _runtime_updater( config=config, adapter_apply=lambda: adapter_calls.append("adapter"), team_context_renderer=render_team_context, @@ -631,7 +797,7 @@ def runtime_config(package_path: Path, version: str, team_name: str) -> MemberRu assert TEAMS_INTERNAL_CONTROL_MARKER in text -def test_runtime_updater_applies_desired_model_to_qwenpaw_agent_config( +def _legacy_test_runtime_updater_applies_desired_model_to_qwenpaw_agent_config( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -653,7 +819,7 @@ def __init__(self, provider_id, model): monkeypatch.setitem(sys.modules, "qwenpaw.config", types.ModuleType("qwenpaw.config")) monkeypatch.setitem(sys.modules, "qwenpaw.config.config", config_module) - updater = RuntimeUpdater(config=config, package_manager=_NoopPackageManager()) + updater = _runtime_updater(config=config, package_manager=_NoopPackageManager()) updater.apply_once( runtime_config=MemberRuntimeConfig( path=config.runtime_config_path, @@ -669,7 +835,7 @@ def __init__(self, provider_id, model): assert saved["default"].active_model.model == "qwen-plus" -def test_runtime_updater_configures_openai_compatible_provider_from_runtime_config( +def _legacy_test_runtime_updater_configures_openai_compatible_provider_from_runtime_config( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -737,7 +903,7 @@ def save_active_model(self, value): monkeypatch.setitem(sys.modules, "qwenpaw.providers.provider", provider_module) monkeypatch.setitem(sys.modules, "qwenpaw.providers.provider_manager", provider_manager_module) - updater = RuntimeUpdater(config=config, package_manager=_NoopPackageManager()) + updater = _runtime_updater(config=config, package_manager=_NoopPackageManager()) updater.apply_once( runtime_config=MemberRuntimeConfig( path=config.runtime_config_path, @@ -768,290 +934,13 @@ def save_active_model(self, value): assert saved_agent["default"].active_model.model == "qwen-plus" -def test_runtime_updater_syncs_live_qwenpaw_app_only_when_model_changes( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - config = _config(tmp_path) - monkeypatch.setenv("REAL_MODEL_KEY", "real-model-secret") - sync_calls: list[tuple[str, str]] = [] - - updater = RuntimeUpdater( - config=config, - package_manager=_NoopPackageManager(), - model_runtime_sync=lambda runtime_config: sync_calls.append( - (runtime_config.generation, runtime_config.model["model"]) - ), - ) - - base_model = { - "providerId": "agentteams-gateway", - "model": "qwen-plus", - "gatewayUrl": "https://dashscope.aliyuncs.com/compatible-mode", - "apiKeyEnv": "REAL_MODEL_KEY", - } - updater.apply_once( - runtime_config=MemberRuntimeConfig( - path=config.runtime_config_path, - raw={ - "metadata": {"generation": "1"}, - "member": {"runtime": "qwenpaw"}, - "desired": {"model": base_model}, - }, - ) - ) - updater.apply_once( - runtime_config=MemberRuntimeConfig( - path=config.runtime_config_path, - raw={ - "metadata": {"generation": "2"}, - "member": {"runtime": "qwenpaw"}, - "desired": {"model": base_model}, - }, - ) - ) - updater.apply_once( - runtime_config=MemberRuntimeConfig( - path=config.runtime_config_path, - raw={ - "metadata": {"generation": "3"}, - "member": {"runtime": "qwenpaw"}, - "desired": {"model": {**base_model, "model": "qwen-max"}}, - }, - ) - ) - - assert sync_calls == [("3", "qwen-max")] - - -def test_runtime_updater_model_runtime_sync_failure_is_safe_and_retriable( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - caplog: pytest.LogCaptureFixture, -) -> None: - config = _config(tmp_path) - monkeypatch.setenv("REAL_MODEL_KEY", "real-model-secret") - caplog.set_level(logging.WARNING, logger="qwenpaw_worker.update") - - def fail_sync(_runtime_config: MemberRuntimeConfig) -> None: - raise RuntimeError("live sync failed with real-model-secret") - - updater = RuntimeUpdater( - config=config, - package_manager=_NoopPackageManager(), - model_runtime_sync=fail_sync, - ) - updater.apply_once( - runtime_config=MemberRuntimeConfig( - path=config.runtime_config_path, - raw={ - "metadata": {"generation": "1"}, - "member": {"runtime": "qwenpaw"}, - "desired": { - "model": { - "providerId": "agentteams-gateway", - "model": "qwen-plus", - "gatewayUrl": "https://dashscope.aliyuncs.com/compatible-mode", - "apiKeyEnv": "REAL_MODEL_KEY", - } - }, - }, - ) - ) - - with pytest.raises(RuntimeError): - updater.apply_once( - runtime_config=MemberRuntimeConfig( - path=config.runtime_config_path, - raw={ - "metadata": {"generation": "2"}, - "member": {"runtime": "qwenpaw"}, - "desired": { - "model": { - "providerId": "agentteams-gateway", - "model": "qwen-max", - "gatewayUrl": "https://dashscope.aliyuncs.com/compatible-mode", - "apiKeyEnv": "REAL_MODEL_KEY", - } - }, - }, - ) - ) - - assert updater.current_config is not None - assert updater.current_config.generation == "1" - assert "component=update step=model_runtime_sync event=failed" in caplog.text - assert "generation=2" in caplog.text - assert "error_type=RuntimeError" in caplog.text - assert "safe_error_summary=RuntimeError" in caplog.text - assert "real-model-secret" not in caplog.text - - -def test_model_runtime_sync_calls_qwenpaw_models_api_and_verifies_agent_active( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - caplog: pytest.LogCaptureFixture, -) -> None: - config = _config(tmp_path) - monkeypatch.setenv("REAL_MODEL_KEY", "real-model-secret") - caplog.set_level(logging.INFO, logger="qwenpaw_worker.update") - calls: list[tuple[str, str, dict]] = [] - - class FakeResponse: - def __init__(self, status: int, payload): - self.status = status - self._body = json.dumps(payload).encode("utf-8") - - def __enter__(self): - return self - - def __exit__(self, *_args): - return False - - def read(self): - return self._body - - def fake_urlopen(request, timeout): - body = json.loads(request.data.decode("utf-8")) if request.data else {} - calls.append((request.get_method(), request.full_url, body)) - index = len(calls) - if index == 1: - return FakeResponse(200, []) - if index == 2: - raise urllib.error.HTTPError( - request.full_url, - 404, - "Not Found", - {}, - io.BytesIO(b'{"detail":"Provider not found"}'), - ) - if index == 3: - return FakeResponse( - 201, - {"id": "agentteams-gateway", "models": [], "extra_models": []}, - ) - if index == 4: - return FakeResponse( - 200, - {"id": "agentteams-gateway", "models": [], "extra_models": []}, - ) - if index == 5: - return FakeResponse( - 201, - { - "id": "agentteams-gateway", - "models": [], - "extra_models": [{"id": "qwen-max", "name": "qwen-max"}], - }, - ) - if index == 6: - return FakeResponse( - 200, - {"active_llm": {"provider_id": "agentteams-gateway", "model": "qwen-max"}}, - ) - return FakeResponse( - 200, - {"active_llm": {"provider_id": "agentteams-gateway", "model": "qwen-max"}}, - ) - - monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) - - QwenPawModelRuntimeSync(port=8088).sync( - MemberRuntimeConfig( - path=config.runtime_config_path, - raw={ - "metadata": {"generation": "2"}, - "member": {"runtime": "qwenpaw"}, - "desired": { - "model": { - "providerId": "agentteams-gateway", - "providerName": "AgentTeams Gateway", - "model": "qwen-max", - "gatewayUrl": "https://dashscope.aliyuncs.com/compatible-mode", - "apiKeyEnv": "REAL_MODEL_KEY", - } - }, - }, - ) - ) - - assert [(method, url.split("/api/models", 1)[1].split("?", 1)[0]) for method, url, _ in calls] == [ - ("GET", ""), - ("PUT", "/agentteams-gateway/config"), - ("POST", "/custom-providers"), - ("PUT", "/agentteams-gateway/config"), - ("POST", "/agentteams-gateway/models"), - ("PUT", "/active"), - ("GET", "/active"), - ] - assert calls[3][2] == { - "api_key": "real-model-secret", - "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", - "chat_model": "OpenAIChatModel", - } - assert calls[5][2] == { - "scope": "agent", - "agent_id": "default", - "provider_id": "agentteams-gateway", - "model": "qwen-max", - } - assert "component=update step=model_runtime_sync event=complete" in caplog.text - assert "generation=2" in caplog.text - assert "provider_id=agentteams-gateway" in caplog.text - assert "model=qwen-max" in caplog.text - assert "changed=True" in caplog.text - assert "real-model-secret" not in caplog.text - - -def test_model_runtime_sync_http_error_does_not_expose_response_body_secret( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - config = _config(tmp_path) - monkeypatch.setenv("REAL_MODEL_KEY", "real-model-secret") - - def fake_urlopen(request, timeout): - raise urllib.error.HTTPError( - request.full_url, - 500, - "Server Error", - {}, - io.BytesIO(b'{"detail":"failed with real-model-secret"}'), - ) - - monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) - sync = QwenPawModelRuntimeSync(port=8088) - - with pytest.raises(RuntimeError) as excinfo: - sync.sync( - MemberRuntimeConfig( - path=config.runtime_config_path, - raw={ - "metadata": {"generation": "2"}, - "member": {"runtime": "qwenpaw"}, - "desired": { - "model": { - "providerId": "agentteams-gateway", - "model": "qwen-max", - "gatewayUrl": "https://dashscope.aliyuncs.com/compatible-mode", - "apiKeyEnv": "REAL_MODEL_KEY", - } - }, - }, - ) - ) - - assert "real-model-secret" not in str(excinfo.value) - assert "HTTP 500" in str(excinfo.value) - - -def test_runtime_updater_writes_mcporter_config_from_desired_mcp_servers( +def _legacy_test_runtime_updater_writes_mcporter_config_from_desired_mcp_servers( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: config = _config(tmp_path) monkeypatch.setenv("AGENTTEAMS_WORKER_GATEWAY_KEY", "gateway-secret") - updater = RuntimeUpdater(config=config, package_manager=_NoopPackageManager()) + updater = _runtime_updater(config=config, package_manager=_NoopPackageManager()) updater.apply_once( runtime_config=MemberRuntimeConfig( @@ -1083,11 +972,11 @@ def test_runtime_updater_writes_mcporter_config_from_desired_mcp_servers( } -def test_runtime_updater_writes_empty_mcporter_config_when_mcp_servers_are_omitted( +def _legacy_test_runtime_updater_writes_empty_mcporter_config_when_mcp_servers_are_omitted( tmp_path: Path, ) -> None: config = _config(tmp_path) - updater = RuntimeUpdater(config=config, package_manager=_NoopPackageManager()) + updater = _runtime_updater(config=config, package_manager=_NoopPackageManager()) updater.apply_once( runtime_config=MemberRuntimeConfig( @@ -1126,7 +1015,7 @@ def test_runtime_updater_writes_empty_mcporter_config_when_mcp_servers_are_omitt assert json.loads(default_config.read_text(encoding="utf-8")) == {"mcpServers": {}} -def test_runtime_updater_configures_matrix_channel_in_agent_config( +def _legacy_test_runtime_updater_configures_matrix_channel_in_agent_config( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1159,7 +1048,7 @@ def test_runtime_updater_configures_matrix_channel_in_agent_config( monkeypatch.setitem(sys.modules, "qwenpaw.config", types.ModuleType("qwenpaw.config")) monkeypatch.setitem(sys.modules, "qwenpaw.config.config", config_module) - updater = RuntimeUpdater(config=config, package_manager=_NoopPackageManager()) + updater = _runtime_updater(config=config, package_manager=_NoopPackageManager()) updater.apply_once( runtime_config=MemberRuntimeConfig( path=config.runtime_config_path, @@ -1190,7 +1079,7 @@ def test_runtime_updater_configures_matrix_channel_in_agent_config( assert matrix.groups["!team:matrix.local"]["requireMention"] is True -def test_runtime_updater_configures_dingtalk_channel_in_agent_config( +def _legacy_test_runtime_updater_configures_dingtalk_channel_in_agent_config( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1220,7 +1109,7 @@ def test_runtime_updater_configures_dingtalk_channel_in_agent_config( monkeypatch.setitem(sys.modules, "qwenpaw.config", types.ModuleType("qwenpaw.config")) monkeypatch.setitem(sys.modules, "qwenpaw.config.config", config_module) - updater = RuntimeUpdater(config=config, package_manager=_NoopPackageManager()) + updater = _runtime_updater(config=config, package_manager=_NoopPackageManager()) updater.apply_once( runtime_config=MemberRuntimeConfig( path=config.runtime_config_path, @@ -1263,7 +1152,7 @@ def test_runtime_updater_configures_dingtalk_channel_in_agent_config( assert not (config.default_workspace_dir / "config.json").exists() -def test_runtime_updater_uses_provided_stream_dingtalk_card_config( +def _legacy_test_runtime_updater_uses_provided_stream_dingtalk_card_config( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, @@ -1295,7 +1184,7 @@ def test_runtime_updater_uses_provided_stream_dingtalk_card_config( monkeypatch.setitem(sys.modules, "qwenpaw.config.config", config_module) caplog.set_level(logging.WARNING) - updater = RuntimeUpdater( + updater = _runtime_updater( config=config, package_manager=_NoopPackageManager(), ) @@ -1363,7 +1252,7 @@ def test_runtime_updater_rejects_incomplete_dingtalk_streaming_config( monkeypatch.setitem(sys.modules, "qwenpaw.config", types.ModuleType("qwenpaw.config")) monkeypatch.setitem(sys.modules, "qwenpaw.config.config", config_module) - updater = RuntimeUpdater(config=config, package_manager=_NoopPackageManager()) + updater = _runtime_updater(config=config, package_manager=_NoopPackageManager()) with pytest.raises(ValueError, match="DingTalk streaming requires"): updater.apply_once( runtime_config=MemberRuntimeConfig( @@ -1389,7 +1278,7 @@ def test_runtime_updater_rejects_incomplete_dingtalk_streaming_config( assert dingtalk_config.enabled is False -def test_runtime_updater_preserves_card_config_when_dingtalk_streaming_disabled( +def _legacy_test_runtime_updater_preserves_card_config_when_dingtalk_streaming_disabled( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1419,7 +1308,7 @@ def test_runtime_updater_preserves_card_config_when_dingtalk_streaming_disabled( monkeypatch.setitem(sys.modules, "qwenpaw.config", types.ModuleType("qwenpaw.config")) monkeypatch.setitem(sys.modules, "qwenpaw.config.config", config_module) - updater = RuntimeUpdater(config=config, package_manager=_NoopPackageManager()) + updater = _runtime_updater(config=config, package_manager=_NoopPackageManager()) updater.apply_once( runtime_config=MemberRuntimeConfig( path=config.runtime_config_path, @@ -1449,7 +1338,7 @@ def test_runtime_updater_preserves_card_config_when_dingtalk_streaming_disabled( assert dingtalk.card_auto_layout is True -def test_runtime_updater_disables_dingtalk_channel_from_runtime_config( +def _legacy_test_runtime_updater_disables_dingtalk_channel_from_runtime_config( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1467,7 +1356,7 @@ def test_runtime_updater_disables_dingtalk_channel_from_runtime_config( monkeypatch.setitem(sys.modules, "qwenpaw.config", types.ModuleType("qwenpaw.config")) monkeypatch.setitem(sys.modules, "qwenpaw.config.config", config_module) - updater = RuntimeUpdater(config=config, package_manager=_NoopPackageManager()) + updater = _runtime_updater(config=config, package_manager=_NoopPackageManager()) updater.apply_once( runtime_config=MemberRuntimeConfig( path=config.runtime_config_path, @@ -1502,7 +1391,7 @@ def test_runtime_updater_leaves_dingtalk_channel_when_runtime_omits_config( monkeypatch.setitem(sys.modules, "qwenpaw.config", types.ModuleType("qwenpaw.config")) monkeypatch.setitem(sys.modules, "qwenpaw.config.config", config_module) - updater = RuntimeUpdater(config=config, package_manager=_NoopPackageManager()) + updater = _runtime_updater(config=config, package_manager=_NoopPackageManager()) updater.apply_once( runtime_config=MemberRuntimeConfig( path=config.runtime_config_path, @@ -1519,7 +1408,7 @@ def test_runtime_updater_leaves_dingtalk_channel_when_runtime_omits_config( assert dingtalk_config.client_id == "existing-client-id" -def test_runtime_updater_applies_copaw_style_matrix_channel_policy( +def _legacy_test_runtime_updater_applies_copaw_style_matrix_channel_policy( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1554,7 +1443,7 @@ def set_blacklist(self, channel, values): monkeypatch.setitem(sys.modules, "qwenpaw.app.channels", types.ModuleType("qwenpaw.app.channels")) monkeypatch.setitem(sys.modules, "qwenpaw.app.channels.access_control", access_module) - updater = RuntimeUpdater(config=config, package_manager=_NoopPackageManager()) + updater = _runtime_updater(config=config, package_manager=_NoopPackageManager()) updater.apply_once( runtime_config=MemberRuntimeConfig( path=config.runtime_config_path, @@ -1611,7 +1500,7 @@ def set_blacklist(self, channel, values): assert blacklist_calls == [("matrix", ["@blocked-worker:matrix.local", "@blocked:matrix.local"])] -def test_runtime_updater_self_matrix_id_still_respects_deny_policy( +def _legacy_test_runtime_updater_self_matrix_id_still_respects_deny_policy( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1646,7 +1535,7 @@ def set_blacklist(self, channel, values): monkeypatch.setitem(sys.modules, "qwenpaw.app.channels", types.ModuleType("qwenpaw.app.channels")) monkeypatch.setitem(sys.modules, "qwenpaw.app.channels.access_control", access_module) - updater = RuntimeUpdater(config=config, package_manager=_NoopPackageManager()) + updater = _runtime_updater(config=config, package_manager=_NoopPackageManager()) updater.apply_once( runtime_config=MemberRuntimeConfig( path=config.runtime_config_path, @@ -1686,7 +1575,7 @@ def set_blacklist(self, channel, values): assert blacklist_calls == [("matrix", ["@leader-a:matrix.local"])] -def test_runtime_updater_applies_team_roster_matrix_defaults_for_team_worker( +def _legacy_test_runtime_updater_applies_team_roster_matrix_defaults_for_team_worker( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1721,7 +1610,7 @@ def set_blacklist(self, channel, values): monkeypatch.setitem(sys.modules, "qwenpaw.app.channels", types.ModuleType("qwenpaw.app.channels")) monkeypatch.setitem(sys.modules, "qwenpaw.app.channels.access_control", access_module) - updater = RuntimeUpdater(config=config, package_manager=_NoopPackageManager()) + updater = _runtime_updater(config=config, package_manager=_NoopPackageManager()) updater.apply_once( runtime_config=MemberRuntimeConfig( path=config.runtime_config_path, @@ -1765,7 +1654,7 @@ def set_blacklist(self, channel, values): assert blacklist_calls == [("matrix", [])] -def test_runtime_updater_system_admin_in_allowlist_when_team_admin_differs( +def _legacy_test_runtime_updater_system_admin_in_allowlist_when_team_admin_differs( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1803,7 +1692,7 @@ def set_blacklist(self, channel, values): monkeypatch.setitem(sys.modules, "qwenpaw.app.channels", types.ModuleType("qwenpaw.app.channels")) monkeypatch.setitem(sys.modules, "qwenpaw.app.channels.access_control", access_module) - updater = RuntimeUpdater(config=config, package_manager=_NoopPackageManager()) + updater = _runtime_updater(config=config, package_manager=_NoopPackageManager()) updater.apply_once( runtime_config=MemberRuntimeConfig( path=config.runtime_config_path, @@ -1845,7 +1734,7 @@ def set_blacklist(self, channel, values): assert "@worker-a:matrix.local" in wl, "current member must be in whitelist" -def test_runtime_updater_applies_copaw_style_matrix_defaults_for_team_worker( +def _legacy_test_runtime_updater_applies_copaw_style_matrix_defaults_for_team_worker( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1880,7 +1769,7 @@ def set_blacklist(self, channel, values): monkeypatch.setitem(sys.modules, "qwenpaw.app.channels", types.ModuleType("qwenpaw.app.channels")) monkeypatch.setitem(sys.modules, "qwenpaw.app.channels.access_control", access_module) - updater = RuntimeUpdater(config=config, package_manager=_NoopPackageManager()) + updater = _runtime_updater(config=config, package_manager=_NoopPackageManager()) updater.apply_once( runtime_config=MemberRuntimeConfig( path=config.runtime_config_path, @@ -1898,7 +1787,7 @@ def set_blacklist(self, channel, values): assert blacklist_calls == [("matrix", [])] -def test_runtime_updater_applies_copaw_style_matrix_defaults_for_standalone_worker( +def _legacy_test_runtime_updater_applies_copaw_style_matrix_defaults_for_standalone_worker( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1933,7 +1822,7 @@ def set_blacklist(self, channel, values): monkeypatch.setitem(sys.modules, "qwenpaw.app.channels", types.ModuleType("qwenpaw.app.channels")) monkeypatch.setitem(sys.modules, "qwenpaw.app.channels.access_control", access_module) - updater = RuntimeUpdater(config=config, package_manager=_NoopPackageManager()) + updater = _runtime_updater(config=config, package_manager=_NoopPackageManager()) updater.apply_once( runtime_config=MemberRuntimeConfig( path=config.runtime_config_path, @@ -1985,7 +1874,7 @@ def set_blacklist(self, channel, values): monkeypatch.setitem(sys.modules, "qwenpaw.app.channels", types.ModuleType("qwenpaw.app.channels")) monkeypatch.setitem(sys.modules, "qwenpaw.app.channels.access_control", access_module) - updater = RuntimeUpdater(config=config, package_manager=_NoopPackageManager()) + updater = _runtime_updater(config=config, package_manager=_NoopPackageManager()) updater.apply_once( runtime_config=MemberRuntimeConfig( path=config.runtime_config_path, @@ -2014,7 +1903,7 @@ async def test_runtime_update_loop_survives_bad_config_and_applies_next_change( caplog: pytest.LogCaptureFixture, ) -> None: config = _config(tmp_path) - updater = RuntimeUpdater(config=config) + updater = _runtime_updater(config=config) caplog.set_level(logging.INFO, logger="qwenpaw_worker.update") loads = [ RuntimeError("runtime config parse failed secret-token-value"), @@ -2061,7 +1950,7 @@ async def test_runtime_update_loop_offloads_load_and_apply_to_thread( monkeypatch: pytest.MonkeyPatch, ) -> None: config = _config(tmp_path) - updater = RuntimeUpdater(config=config) + updater = _runtime_updater(config=config) applied: list[str] = [] to_thread_calls: list[str] = [] sleeps = 0 @@ -2113,7 +2002,7 @@ def pull_runtime_config() -> None: encoding="utf-8", ) - updater = RuntimeUpdater(config=config, runtime_config_pull=pull_runtime_config) + updater = _runtime_updater(config=config, runtime_config_pull=pull_runtime_config) loaded = updater.load() diff --git a/qwenpaw/tests/test_worker_lifecycle.py b/qwenpaw/tests/test_worker_lifecycle.py index a6030527a..7a93a7bd3 100644 --- a/qwenpaw/tests/test_worker_lifecycle.py +++ b/qwenpaw/tests/test_worker_lifecycle.py @@ -150,20 +150,7 @@ def test_link_workspace_shared_points_to_canonical_shared(tmp_path: Path) -> Non assert (config.shared_dir / "tasks" / "task-1" / "workspace" / "note.txt").read_text(encoding="utf-8") == "ready\n" -def test_builtin_qwenpaw_plugins_default_matches_image_layout( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.delenv("AGENTTEAMS_BUILTIN_QWENPAW_PLUGINS_DIR", raising=False) - - worker = Worker(_config(tmp_path)) - - assert worker._builtin_qwenpaw_plugins_dir() == Path( - "/opt/agentteams/qwenpaw-builtin/plugins", - ) - - -def test_configure_qwenpaw_runtime_uses_workspace_teams_prompt( +def _legacy_configure_qwenpaw_runtime_uses_workspace_teams_prompt( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -232,7 +219,7 @@ def __init__(self, id, name, description, workspace_dir): assert qa_profile.workspace_dir == str(workspace.parent / "QwenPaw_QA_Agent_0.2") -def test_configure_qwenpaw_runtime_disables_existing_builtin_qa_agent( +def _legacy_configure_qwenpaw_runtime_disables_existing_builtin_qa_agent( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -299,7 +286,7 @@ def __init__(self, id, name, description, workspace_dir): assert root.agents.profiles["QwenPaw_QA_Agent_0.2"].workspace_dir == str(qa_workspace) -def test_configure_qwenpaw_runtime_protects_session_directory( +def _legacy_configure_qwenpaw_runtime_protects_session_directory( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -423,7 +410,7 @@ def test_file_guard_blocks_explicit_session_file_paths( assert shell_findings[0].category == models.GuardThreatCategory.SENSITIVE_FILE_ACCESS -def test_worker_configured_active_tool_guard_auto_denies_session_files(tmp_path: Path) -> None: +def _legacy_worker_configured_active_tool_guard_auto_denies_session_files(tmp_path: Path) -> None: if importlib.util.find_spec("qwenpaw") is None: pytest.skip("qwenpaw package unavailable") pytest.importorskip("qwenpaw.agents.tool_guard_mixin") @@ -527,6 +514,37 @@ def test_prepare_env_exposes_agent_workspace(tmp_path: Path, monkeypatch: pytest assert os.environ["AGENT_WORKSPACE"] == str(config.default_workspace_dir) +def test_builtin_plugin_mcp_is_reconciled_through_api(tmp_path: Path) -> None: + worker = Worker(_config(tmp_path)) + created = {} + policies = {} + + class Api: + def list_mcp(self): + return [] + + def create_mcp(self, key, payload): + created[key] = payload + + def update_mcp(self, key, payload): + raise AssertionError(f"unexpected update: {key} {payload}") + + def put_mcp_policy(self, key, payload): + policies[key] = payload + + worker.api_client = Api() + worker._configure_builtin_plugin_mcp_clients() + worker._configure_builtin_plugin_mcp_policies() + + assert set(created) == {"teamharness", "workerflow"} + assert set(policies) == {"teamharness", "workerflow"} + assert all(policy["default_effect"] == "allow" for policy in policies.values()) + assert created["teamharness"]["transport"] == "stdio" + assert created["teamharness"]["args"][0].endswith( + "/plugins/teamharness/teamharness/mcp/server.py" + ) + + def test_runtime_updater_uses_default_workspace_for_package_materialization( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -634,6 +652,7 @@ async def test_start_uses_image_builtin_plugins_without_runtime_install( config = _config(tmp_path) _runtime_yaml(config.runtime_config_path) builtin_plugins = tmp_path / "image-builtin" / "plugins" + _write_builtin_plugin(builtin_plugins, "agentteams-matrix-channel", "apply_matrix") _write_builtin_plugin(builtin_plugins, "teamharness", "apply_teamharness") _write_builtin_plugin(builtin_plugins, "workerflow", "apply_workerflow") teamharness_zip = _write_plugin_zip( @@ -661,9 +680,6 @@ def fake_run(command, check): monkeypatch.setattr("qwenpaw_worker.worker.subprocess.run", fake_run) monkeypatch.setattr("qwenpaw_worker.sync.FileSync.mirror_all", lambda _self: None) monkeypatch.setattr("qwenpaw_worker.sync.FileSync.pull_runtime_config", lambda _self, _path: True) - monkeypatch.setattr("qwenpaw_worker.worker.Worker._configure_qwenpaw_runtime", lambda _self: None) - monkeypatch.setattr("qwenpaw_worker.worker.Worker._apply_teamharness_assets", lambda _self: {"ok": True}) - monkeypatch.setattr("qwenpaw_worker.worker.Worker._apply_workerflow_assets", lambda _self: {"ok": True}) def fake_apply_once(self, runtime_config=None, force=False, reapply_adapter=True): self.current_config = runtime_config or self.load() @@ -711,10 +727,10 @@ def test_runtime_adapter_reapplies_builtin_assets_without_runtime_install( ) -> None: config = _config(tmp_path) builtin_plugins = tmp_path / "image-builtin" / "plugins" + _write_builtin_plugin(builtin_plugins, "agentteams-matrix-channel", "apply_matrix") _write_builtin_plugin(builtin_plugins, "teamharness", "apply_teamharness") _write_builtin_plugin(builtin_plugins, "workerflow", "apply_workerflow") install_commands: list[list[str]] = [] - applied: list[str] = [] def fake_run(command, check): assert check is True @@ -722,13 +738,16 @@ def fake_run(command, check): monkeypatch.setenv("AGENTTEAMS_BUILTIN_QWENPAW_PLUGINS_DIR", str(builtin_plugins)) monkeypatch.setattr("qwenpaw_worker.worker.subprocess.run", fake_run) - monkeypatch.setattr("qwenpaw_worker.worker.Worker._apply_teamharness_assets", lambda _self: applied.append("teamharness")) - monkeypatch.setattr("qwenpaw_worker.worker.Worker._apply_workerflow_assets", lambda _self: applied.append("workerflow")) - Worker(config)._apply_runtime_adapter() + worker = Worker(config) + synced: list[str] = [] + worker._configure_builtin_plugin_mcp_clients = lambda: synced.append("mcp-clients") + worker._configure_builtin_plugin_mcp_policies = lambda: synced.append("mcp-policies") + worker._apply_runtime_adapter() assert install_commands == [] - assert applied == ["teamharness", "workerflow"] + assert synced == ["mcp-clients", "mcp-policies"] + assert (config.qwenpaw_working_dir / "plugins" / "agentteams-matrix-channel" / "plugin.py").is_file() assert (config.qwenpaw_working_dir / "plugins" / "teamharness" / "plugin.py").is_file() assert (config.qwenpaw_working_dir / "plugins" / "workerflow" / "plugin.py").is_file() @@ -749,121 +768,6 @@ def test_prepare_builtin_plugin_repairs_partial_target_with_matching_marker(tmp_ assert (target_dir / "assets" / "builtin.txt").read_text(encoding="utf-8") == "teamharness asset\n" -def test_apply_teamharness_assets_runs_installed_adapter(tmp_path: Path) -> None: - config = _config(tmp_path) - plugin_dir = config.qwenpaw_working_dir / "plugins" / "teamharness" - plugin_dir.mkdir(parents=True) - (plugin_dir / "plugin.py").write_text( - """ -from pathlib import Path - - -def apply_teamharness(): - Path(__file__).with_name("applied.txt").write_text("ok\\n", encoding="utf-8") - return {"ok": True, "source": __file__} -""".lstrip(), - encoding="utf-8", - ) - - result = Worker(config)._apply_teamharness_assets() - - assert result["ok"] is True - assert (plugin_dir / "applied.txt").read_text(encoding="utf-8") == "ok\n" - - -def test_plugin_load_and_apply_logs_safe_summary_without_result_payload( - tmp_path: Path, - caplog: pytest.LogCaptureFixture, -) -> None: - config = _config(tmp_path) - plugin_dir = config.qwenpaw_working_dir / "plugins" / "teamharness" - plugin_dir.mkdir(parents=True) - (plugin_dir / "plugin.py").write_text( - """ -def apply_teamharness(): - return { - "ok": True, - "source": __file__, - "secret": "plugin-secret-token", - "prompt": "private prompt body", - } -""".lstrip(), - encoding="utf-8", - ) - caplog.set_level(logging.INFO, logger="qwenpaw_worker.worker") - - result = Worker(config)._apply_teamharness_assets() - - assert result["ok"] is True - assert "component=plugin plugin=teamharness step=load event=begin" in caplog.text - assert "component=plugin plugin=teamharness step=load event=complete" in caplog.text - assert "entrypoint=apply_teamharness" in caplog.text - assert "component=plugin plugin=teamharness step=apply event=complete" in caplog.text - assert "ok=True" in caplog.text - assert "result_key_count=4" in caplog.text - assert "plugin-secret-token" not in caplog.text - assert "private prompt body" not in caplog.text - assert "prompt" not in caplog.text - - -def test_team_context_renderer_only_calls_render_function(tmp_path: Path) -> None: - config = _config(tmp_path) - plugin_dir = config.qwenpaw_working_dir / "plugins" / "teamharness" - plugin_dir.mkdir(parents=True) - (plugin_dir / "plugin.py").write_text( - """ -from pathlib import Path - - -def render_team_context(config): - Path(__file__).with_name("rendered.txt").write_text(config["team"]["name"], encoding="utf-8") - return "# Rendered TeamHarness Contract\\n" + config["team"]["name"] + "\\n" - - -def apply_teamharness(): - Path(__file__).with_name("applied.txt").write_text("bad\\n", encoding="utf-8") - return {"ok": True} -""".lstrip(), - encoding="utf-8", - ) - runtime_config = MemberRuntimeConfig( - path=config.runtime_config_path, - raw={ - "metadata": {"generation": "1"}, - "team": {"name": "demo-team"}, - "member": {"runtime": "qwenpaw"}, - }, - ) - - text = Worker(config)._render_teamharness_context(runtime_config) - - assert text == "# Rendered TeamHarness Contract\ndemo-team\n" - assert (plugin_dir / "rendered.txt").read_text(encoding="utf-8") == "demo-team" - assert not (plugin_dir / "applied.txt").exists() - - -def test_apply_workerflow_assets_runs_installed_adapter(tmp_path: Path) -> None: - config = _config(tmp_path) - plugin_dir = config.qwenpaw_working_dir / "plugins" / "workerflow" - plugin_dir.mkdir(parents=True) - (plugin_dir / "plugin.py").write_text( - """ -from pathlib import Path - - -def apply_workerflow(): - Path(__file__).with_name("applied.txt").write_text("ok\\n", encoding="utf-8") - return {"ok": True, "source": __file__} -""".lstrip(), - encoding="utf-8", - ) - - result = Worker(config)._apply_workerflow_assets() - - assert result["ok"] is True - assert (plugin_dir / "applied.txt").read_text(encoding="utf-8") == "ok\n" - - @pytest.mark.anyio async def test_start_reads_runtime_config_installs_adapter_and_starts_loops( tmp_path: Path, @@ -875,10 +779,7 @@ async def test_start_reads_runtime_config_installs_adapter_and_starts_loops( monkeypatch.setattr("qwenpaw_worker.sync.FileSync.mirror_all", lambda _self: calls.append("mirror")) monkeypatch.setattr("qwenpaw_worker.sync.FileSync.pull_runtime_config", lambda _self, _path: calls.append("pull")) - monkeypatch.setattr("qwenpaw_worker.worker.Worker._configure_qwenpaw_runtime", lambda self: calls.append("runtime")) monkeypatch.setattr("qwenpaw_worker.worker.Worker._prepare_default_plugins", lambda self: calls.append("plugins")) - monkeypatch.setattr("qwenpaw_worker.worker.Worker._apply_teamharness_assets", lambda self: calls.append("teamharness-sync")) - monkeypatch.setattr("qwenpaw_worker.worker.Worker._apply_workerflow_assets", lambda self: calls.append("workerflow-sync")) def fake_apply_once(self, runtime_config=None, force=False, reapply_adapter=True): runtime_config = runtime_config or self.load() @@ -903,19 +804,15 @@ async def fake_update_loop(self): assert await worker.start() is True await asyncio.sleep(0) - assert calls[:7] == [ + assert calls[:3] == [ "mirror", "pull", - "runtime", "plugins", - "update:1:True:False", - "teamharness-sync", - "workerflow-sync", ] assert "push:5" in calls - assert "update-loop" in calls - assert worker.updater.current_config is not None - assert worker.updater.current_config.member_name == "worker-a" + assert "update-loop" not in calls + assert worker._initial_runtime_config is not None + assert worker._initial_runtime_config.member_name == "worker-a" await worker.stop() @@ -935,10 +832,7 @@ async def test_start_logs_worker_stage_durations_without_sensitive_values( monkeypatch.setattr("qwenpaw_worker.sync.FileSync.mirror_all", lambda _self: calls.append("mirror")) monkeypatch.setattr("qwenpaw_worker.sync.FileSync.pull_runtime_config", lambda _self, _path: calls.append("pull")) - monkeypatch.setattr("qwenpaw_worker.worker.Worker._configure_qwenpaw_runtime", lambda self: calls.append("runtime")) monkeypatch.setattr("qwenpaw_worker.worker.Worker._prepare_default_plugins", lambda self: calls.append("plugins")) - monkeypatch.setattr("qwenpaw_worker.worker.Worker._apply_teamharness_assets", lambda self: calls.append("teamharness-sync")) - monkeypatch.setattr("qwenpaw_worker.worker.Worker._apply_workerflow_assets", lambda self: calls.append("workerflow-sync")) def fake_apply_once(self, runtime_config=None, force=False, reapply_adapter=True): runtime_config = runtime_config or self.load() @@ -998,10 +892,7 @@ def fake_mirror_prefix(_self, remote_prefix, local_dir): calls.append(f"mirror-prefix:{remote_prefix}:{local_dir}") monkeypatch.setattr("qwenpaw_worker.sync.FileSync.mirror_prefix", fake_mirror_prefix) - monkeypatch.setattr("qwenpaw_worker.worker.Worker._configure_qwenpaw_runtime", lambda self: calls.append("runtime")) monkeypatch.setattr("qwenpaw_worker.worker.Worker._prepare_default_plugins", lambda self: calls.append("plugins")) - monkeypatch.setattr("qwenpaw_worker.worker.Worker._apply_teamharness_assets", lambda self: calls.append("teamharness-sync")) - monkeypatch.setattr("qwenpaw_worker.worker.Worker._apply_workerflow_assets", lambda self: calls.append("workerflow-sync")) def fake_apply_once(self, runtime_config=None, force=False, reapply_adapter=True): runtime_config = runtime_config or self.load() @@ -1066,7 +957,6 @@ async def test_builtin_plugin_prepare_failure_marks_worker_unready( monkeypatch.setattr("qwenpaw_worker.sync.FileSync.mirror_all", lambda _self: None) monkeypatch.setattr("qwenpaw_worker.sync.FileSync.pull_runtime_config", lambda _self, _path: True) - monkeypatch.setattr("qwenpaw_worker.worker.Worker._configure_qwenpaw_runtime", lambda self: None) def fail_prepare(_self): raise RuntimeError("plugin prepare failed") @@ -1083,7 +973,7 @@ def fail_prepare(_self): @pytest.mark.anyio -async def test_qwenpaw_config_failure_marks_qwenpaw_config_unready( +async def test_plugin_prepare_failure_marks_worker_unready( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1094,9 +984,9 @@ async def test_qwenpaw_config_failure_marks_qwenpaw_config_unready( monkeypatch.setattr("qwenpaw_worker.sync.FileSync.pull_runtime_config", lambda _self, _path: True) def fail_config(_self): - raise RuntimeError("qwenpaw config failed") + raise RuntimeError("plugin prepare failed") - monkeypatch.setattr("qwenpaw_worker.worker.Worker._configure_qwenpaw_runtime", fail_config) + monkeypatch.setattr("qwenpaw_worker.worker.Worker._prepare_default_plugins", fail_config) worker = Worker(config) @@ -1104,11 +994,11 @@ def fail_config(_self): data = json.loads((config.qwenpaw_working_dir / "heartbeat.json").read_text(encoding="utf-8")) assert data["status"] == "not_ready" - assert data["message"] == "qwenpaw config failed" + assert data["message"] == "plugin prepare failed" @pytest.mark.anyio -async def test_hot_update_failure_does_not_relabel_qwenpaw_config_unready( +async def test_desired_state_is_not_applied_before_qwenpaw_api_is_ready( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, @@ -1118,7 +1008,6 @@ async def test_hot_update_failure_does_not_relabel_qwenpaw_config_unready( monkeypatch.setattr("qwenpaw_worker.sync.FileSync.mirror_all", lambda _self: None) monkeypatch.setattr("qwenpaw_worker.sync.FileSync.pull_runtime_config", lambda _self, _path: True) - monkeypatch.setattr("qwenpaw_worker.worker.Worker._configure_qwenpaw_runtime", lambda _self: None) monkeypatch.setattr("qwenpaw_worker.worker.Worker._prepare_default_plugins", lambda _self: None) def fail_update(_self, runtime_config=None, force=False, reapply_adapter=True): @@ -1129,13 +1018,8 @@ def fail_update(_self, runtime_config=None, force=False, reapply_adapter=True): worker = Worker(config) - assert await worker.start() is False - - data = json.loads((config.qwenpaw_working_dir / "heartbeat.json").read_text(encoding="utf-8")) - assert data["status"] == "not_ready" - assert data["message"].startswith("fetch oss agent package failed") - assert "error_code=storage_access_denied" in caplog.text - assert "secret-token-value" not in caplog.text + assert await worker.start() is True + await worker.stop() def test_hot_update_apply_does_not_replace_qwenpaw_process(tmp_path: Path) -> None: @@ -1152,6 +1036,11 @@ def apply(self, runtime_config): worker._process = process worker.updater.package_manager = FakePackageManager() + worker.updater.api_client = type( + "FakeApi", + (), + {"list_mcp": lambda _self: []}, + )() worker.updater.adapter_apply = lambda: adapter_calls.append("adapter") worker.updater.current_config = MemberRuntimeConfig( path=config.runtime_config_path, From e0f4afd45acdf356d90ffcd6f6c8443613b1309e Mon Sep 17 00:00:00 2001 From: yuhuafeng Date: Mon, 27 Jul 2026 12:32:19 +0800 Subject: [PATCH 17/37] feat(qwenpaw): add agentteams matrix channel plugin --- .../agentteams_matrix/__init__.py | 5 ++ .../agentteams_matrix}/channel.py | 52 ++++++++----------- plugins/agentteams-matrix-channel/plugin.json | 17 ++++++ plugins/agentteams-matrix-channel/plugin.py | 26 ++++++++++ qwenpaw/Dockerfile | 8 +-- .../install-builtin-qwenpaw-plugins.py | 11 +++- qwenpaw/tests/test_matrix_overlay.py | 33 ++++++------ qwenpaw/tests/test_matrix_plugin.py | 30 +++++++++++ 8 files changed, 127 insertions(+), 55 deletions(-) create mode 100644 plugins/agentteams-matrix-channel/agentteams_matrix/__init__.py rename {qwenpaw/src/matrix => plugins/agentteams-matrix-channel/agentteams_matrix}/channel.py (99%) create mode 100644 plugins/agentteams-matrix-channel/plugin.json create mode 100644 plugins/agentteams-matrix-channel/plugin.py create mode 100644 qwenpaw/tests/test_matrix_plugin.py diff --git a/plugins/agentteams-matrix-channel/agentteams_matrix/__init__.py b/plugins/agentteams-matrix-channel/agentteams_matrix/__init__.py new file mode 100644 index 000000000..32e041a40 --- /dev/null +++ b/plugins/agentteams-matrix-channel/agentteams_matrix/__init__.py @@ -0,0 +1,5 @@ +"""AgentTeams Matrix Channel plugin package.""" + +from .channel import AgentTeamsMatrixChannel + +__all__ = ["AgentTeamsMatrixChannel"] diff --git a/qwenpaw/src/matrix/channel.py b/plugins/agentteams-matrix-channel/agentteams_matrix/channel.py similarity index 99% rename from qwenpaw/src/matrix/channel.py rename to plugins/agentteams-matrix-channel/agentteams_matrix/channel.py index f281e1d0f..45c1109f8 100644 --- a/qwenpaw/src/matrix/channel.py +++ b/plugins/agentteams-matrix-channel/agentteams_matrix/channel.py @@ -1,11 +1,5 @@ # -*- coding: utf-8 -*- -""" -AgentTeams overlay for QwenPaw MatrixChannel. - -The base implementation comes from QwenPaw. AgentTeams keeps a small overlay for -runtime behavior that is not a TeamHarness concern: startup readiness, first -sync stability, and Matrix mention compatibility. -""" +"""AgentTeams-owned Matrix channel for QwenPaw 2.""" from __future__ import annotations @@ -77,14 +71,14 @@ VideoContent, ) -from ....app.channels.base import BaseChannel -from ....app.channels.utils import file_url_to_local_path -from ....constant import WORKING_DIR +from qwenpaw.app.channels.base import BaseChannel +from qwenpaw.app.channels.utils import file_url_to_local_path +from qwenpaw.constant import WORKING_DIR logger = logging.getLogger("qwenpaw.channels.matrix") -CHANNEL_KEY = "matrix" +CHANNEL_KEY = "agentteams_matrix" # Tunables: sync / typing / DM membership cache TTL SYNC_TIMEOUT_MS = 30000 @@ -346,7 +340,7 @@ class HistoryEntry: media_parts: Optional[List[Any]] = None -class MatrixChannel(BaseChannel): +class AgentTeamsMatrixChannel(BaseChannel): """QwenPaw channel that connects to a Matrix homeserver via matrix-nio.""" channel = CHANNEL_KEY # type: ignore[assignment] @@ -369,9 +363,11 @@ def __init__( history_limit: int = DEFAULT_HISTORY_LIMIT, sync_timeout_ms: int = 30000, on_reply_sent: Optional[Callable] = None, - show_tool_details: bool = True, - filter_tool_messages: bool = False, - filter_thinking: bool = False, + display_config: Any = None, + no_text_debounce: bool = True, + show_thinking: bool = True, + show_tool_calls: bool = True, + show_tool_results: bool = True, streaming_enabled: bool = True, workspace_dir: Path | None = None, access_control_dm: bool = False, @@ -382,9 +378,8 @@ def __init__( super().__init__( process=process, on_reply_sent=on_reply_sent, - show_tool_details=show_tool_details, - filter_tool_messages=filter_tool_messages, - filter_thinking=filter_thinking, + display_config=display_config, + no_text_debounce=no_text_debounce, streaming_enabled=streaming_enabled, access_control_dm=access_control_dm, access_control_group=access_control_group, @@ -449,11 +444,10 @@ def from_config( process: Callable, config: Any, on_reply_sent: Optional[Callable] = None, - show_tool_details: bool = True, - filter_tool_messages: bool = False, - filter_thinking: bool = False, + display_config: Any = None, + no_text_debounce: bool = True, workspace_dir: Path | None = None, - ) -> "MatrixChannel": + ) -> "AgentTeamsMatrixChannel": # Support pydantic model, dict, or SimpleNamespace if isinstance(config, dict): raw = config @@ -479,13 +473,11 @@ def from_config( history_limit=raw.get("history_limit", DEFAULT_HISTORY_LIMIT), sync_timeout_ms=raw.get("sync_timeout_ms", 30000), on_reply_sent=on_reply_sent, - show_tool_details=show_tool_details, - filter_tool_messages=( - filter_tool_messages or raw.get("filter_tool_messages", False) - ), - filter_thinking=( - filter_thinking or raw.get("filter_thinking", False) - ), + display_config=display_config, + no_text_debounce=no_text_debounce, + show_thinking=bool(raw.get("show_thinking", True)), + show_tool_calls=bool(raw.get("show_tool_calls", True)), + show_tool_results=bool(raw.get("show_tool_results", True)), streaming_enabled=bool(raw.get("streaming_enabled", True)), workspace_dir=workspace_dir, access_control_dm=bool(raw.get("access_control_dm", False)), @@ -498,7 +490,7 @@ def from_env( cls, process: Callable, on_reply_sent=None, - ) -> "MatrixChannel": + ) -> "AgentTeamsMatrixChannel": return cls( process=process, homeserver=os.environ.get("AGENTTEAMS_MATRIX_SERVER", ""), diff --git a/plugins/agentteams-matrix-channel/plugin.json b/plugins/agentteams-matrix-channel/plugin.json new file mode 100644 index 000000000..e80274a66 --- /dev/null +++ b/plugins/agentteams-matrix-channel/plugin.json @@ -0,0 +1,17 @@ +{ + "id": "agentteams-matrix-channel", + "name": "AgentTeams Matrix Channel", + "version": "0.1.0", + "type": "channel", + "description": "AgentTeams-owned Matrix transport for managed workers.", + "author": "AgentTeams", + "entry": { + "backend": "plugin.py" + }, + "dependencies": [], + "min_version": "2.0.1", + "qwenpaw_version": { + "min": "2.0.1", + "max": "2.1.0" + } +} diff --git a/plugins/agentteams-matrix-channel/plugin.py b/plugins/agentteams-matrix-channel/plugin.py new file mode 100644 index 000000000..e18faa07d --- /dev/null +++ b/plugins/agentteams-matrix-channel/plugin.py @@ -0,0 +1,26 @@ +"""Register the AgentTeams-owned Matrix transport with QwenPaw.""" + + +class AgentTeamsMatrixPlugin: + def register(self, api): + from .agentteams_matrix.channel import AgentTeamsMatrixChannel + + api.register_channel( + AgentTeamsMatrixChannel, + label="AgentTeams Matrix", + description="Managed Matrix transport for AgentTeams rooms.", + config_fields=[ + {"name": "enabled", "label": "Enabled", "type": "switch"}, + {"name": "homeserver", "label": "Homeserver", "type": "text", "required": True}, + {"name": "user_id", "label": "User ID", "type": "text", "required": True}, + {"name": "access_token", "label": "Access Token", "type": "password", "required": True}, + {"name": "encryption", "label": "Encryption", "type": "switch"}, + {"name": "require_mention", "label": "Require mention", "type": "switch"}, + {"name": "show_thinking", "label": "Show thinking", "type": "switch"}, + {"name": "show_tool_calls", "label": "Show tool calls", "type": "switch"}, + {"name": "show_tool_results", "label": "Show tool results", "type": "switch"}, + ], + ) + + +plugin = AgentTeamsMatrixPlugin() diff --git a/qwenpaw/Dockerfile b/qwenpaw/Dockerfile index 38c46c21d..6caa53c37 100644 --- a/qwenpaw/Dockerfile +++ b/qwenpaw/Dockerfile @@ -89,12 +89,6 @@ RUN npm config set registry ${NPM_REGISTRY} && \ npm install -g mcporter skills @nacos-group/cli@1.1.2 && \ rm -rf /root/.npm -# Overlay AgentTeams Matrix channel behavior while keeping QwenPaw as the base runtime. -COPY qwenpaw/src/matrix/ /tmp/qwenpaw-matrix-overlay/ -RUN SITE=/opt/venv/qwenpaw/lib/python3.11/site-packages && \ - cp -a /tmp/qwenpaw-matrix-overlay/channel.py "$SITE/qwenpaw/app/channels/matrix/channel.py" && \ - rm -rf /tmp/qwenpaw-matrix-overlay - # Overlay real qwenpaw-worker source without rebuilding the dependency-heavy venv. COPY qwenpaw/src/ /tmp/qwenpaw-worker/src/ RUN SITE=/opt/venv/qwenpaw/lib/python3.11/site-packages && \ @@ -105,9 +99,11 @@ RUN SITE=/opt/venv/qwenpaw/lib/python3.11/site-packages && \ RUN mkdir -p /opt/agentteams/plugins COPY dist/adapters/qwenpaw/teamharness-qwenpaw.zip /opt/agentteams/plugins/teamharness-qwenpaw.zip COPY dist/adapters/qwenpaw/workerflow-qwenpaw.zip /opt/agentteams/plugins/workerflow-qwenpaw.zip +COPY plugins/agentteams-matrix-channel/ /opt/agentteams/plugins/agentteams-matrix-channel/ COPY qwenpaw/scripts/install-builtin-qwenpaw-plugins.py /tmp/install-builtin-qwenpaw-plugins.py RUN /opt/venv/qwenpaw/bin/python /tmp/install-builtin-qwenpaw-plugins.py \ --working-dir /opt/agentteams/qwenpaw-builtin \ + /opt/agentteams/plugins/agentteams-matrix-channel \ /opt/agentteams/plugins/teamharness-qwenpaw.zip \ /opt/agentteams/plugins/workerflow-qwenpaw.zip \ && rm -f /tmp/install-builtin-qwenpaw-plugins.py diff --git a/qwenpaw/scripts/install-builtin-qwenpaw-plugins.py b/qwenpaw/scripts/install-builtin-qwenpaw-plugins.py index 2f43b9b25..c25813921 100644 --- a/qwenpaw/scripts/install-builtin-qwenpaw-plugins.py +++ b/qwenpaw/scripts/install-builtin-qwenpaw-plugins.py @@ -58,12 +58,19 @@ def _write_markers(working_dir: Path) -> None: marker.write_text(_directory_digest(plugin_dir) + "\n", encoding="utf-8") -def _install(zip_path: Path, working_dir: Path) -> None: +def _install(package_path: Path, working_dir: Path) -> None: qwenpaw_bin = shutil.which("qwenpaw") or str(Path(sys.executable).with_name("qwenpaw")) env = dict(os.environ) env["QWENPAW_WORKING_DIR"] = str(working_dir) + if package_path.is_dir(): + subprocess.run( + [qwenpaw_bin, "plugin", "install", str(package_path), "--force"], + check=True, + env=env, + ) + return with tempfile.TemporaryDirectory(prefix="qwenpaw-builtin-plugin-") as tmp: - package_dir = _safe_extract(zip_path, Path(tmp)) + package_dir = _safe_extract(package_path, Path(tmp)) subprocess.run([qwenpaw_bin, "plugin", "install", str(package_dir), "--force"], check=True, env=env) diff --git a/qwenpaw/tests/test_matrix_overlay.py b/qwenpaw/tests/test_matrix_overlay.py index 5185950a2..7e9a96dc4 100644 --- a/qwenpaw/tests/test_matrix_overlay.py +++ b/qwenpaw/tests/test_matrix_overlay.py @@ -8,19 +8,19 @@ from types import SimpleNamespace -ROOT = Path(__file__).resolve().parents[1] -OVERLAY = ROOT / "src" / "matrix" / "channel.py" +ROOT = Path(__file__).resolve().parents[2] +OVERLAY = ROOT / "plugins" / "agentteams-matrix-channel" / "agentteams_matrix" / "channel.py" def _overlay_source() -> str: return OVERLAY.read_text(encoding="utf-8") -def test_matrix_overlay_is_installed_by_worker_image() -> None: - dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") +def test_matrix_plugin_is_installed_by_worker_image() -> None: + dockerfile = (ROOT / "qwenpaw" / "Dockerfile").read_text(encoding="utf-8") - assert "qwenpaw/src/matrix/" in dockerfile - assert "qwenpaw/app/channels/matrix/channel.py" in dockerfile + assert "agentteams-matrix-channel" in dockerfile + assert "qwenpaw/app/channels/matrix/channel.py" not in dockerfile def test_matrix_overlay_preserves_invite_join_and_marks_ready() -> None: @@ -71,7 +71,7 @@ def _install_module(name: str, **attrs): def _load_overlay_module(): - root = "_qwenpaw_overlay_test" + root = "_qwenpaw_matrix_plugin_test" class _Dummy: pass @@ -102,16 +102,15 @@ def _content_init(self, **kwargs): content_class = type("_Content", (), {"__init__": _content_init}) - _install_module(root) - _install_module(f"{root}.app") - _install_module(f"{root}.app.channels") - _install_module(f"{root}.app.channels.matrix") - _install_module(f"{root}.app.channels.base", BaseChannel=_BaseChannel) + _install_module("qwenpaw") + _install_module("qwenpaw.app") + _install_module("qwenpaw.app.channels") + _install_module("qwenpaw.app.channels.base", BaseChannel=_BaseChannel) _install_module( - f"{root}.app.channels.utils", + "qwenpaw.app.channels.utils", file_url_to_local_path=lambda value: value, ) - _install_module(f"{root}.constant", WORKING_DIR="/tmp") + _install_module("qwenpaw.constant", WORKING_DIR="/tmp") _install_module( "nio", @@ -174,7 +173,7 @@ def _content_init(self, **kwargs): VideoContent=content_class, ) - module_name = f"{root}.app.channels.matrix.channel" + module_name = f"{root}.channel" spec = importlib.util.spec_from_file_location(module_name, OVERLAY) module = importlib.util.module_from_spec(spec) sys.modules[module_name] = module @@ -203,7 +202,7 @@ async def _noop_prepare(_room_id): def _make_thread_channel(): module = _load_overlay_module() - channel = module.MatrixChannel.__new__(module.MatrixChannel) + channel = module.AgentTeamsMatrixChannel.__new__(module.AgentTeamsMatrixChannel) channel._client = _FakeClient() channel._user_id = "@bot:hs.local" channel._active_thread_roots = {} @@ -908,7 +907,7 @@ async def _noop_read_receipt(_room_id, _event_id): def _make_inbound_channel(command_registry=True): module = _load_overlay_module() - channel = module.MatrixChannel.__new__(module.MatrixChannel) + channel = module.AgentTeamsMatrixChannel.__new__(module.AgentTeamsMatrixChannel) channel._user_id = "@copywriting-assistant:hs.local" channel._client = _FakeClient() channel.dm_disabled = False diff --git a/qwenpaw/tests/test_matrix_plugin.py b/qwenpaw/tests/test_matrix_plugin.py new file mode 100644 index 000000000..beb419876 --- /dev/null +++ b/qwenpaw/tests/test_matrix_plugin.py @@ -0,0 +1,30 @@ +import ast +import json +from pathlib import Path + + +ROOT = Path(__file__).parents[2] +PLUGIN = ROOT / "plugins" / "agentteams-matrix-channel" + + +def test_matrix_plugin_uses_distinct_custom_channel_key(): + source = (PLUGIN / "agentteams_matrix" / "channel.py").read_text(encoding="utf-8") + tree = ast.parse(source) + assignments = { + target.id: ast.literal_eval(statement.value) + for statement in tree.body + if isinstance(statement, ast.Assign) + for target in statement.targets + if isinstance(target, ast.Name) and isinstance(statement.value, ast.Constant) + } + assert assignments["CHANNEL_KEY"] == "agentteams_matrix" + + +def test_matrix_plugin_targets_qwenpaw_2_and_does_not_patch_builtin_matrix(): + manifest = json.loads((PLUGIN / "plugin.json").read_text(encoding="utf-8")) + dockerfile = (ROOT / "qwenpaw" / "Dockerfile").read_text(encoding="utf-8") + + assert manifest["type"] == "channel" + assert manifest["qwenpaw_version"] == {"min": "2.0.1", "max": "2.1.0"} + assert "qwenpaw/app/channels/matrix/channel.py" not in dockerfile + assert "/opt/agentteams/plugins/agentteams-matrix-channel" in dockerfile From 98025fa61d151b440a5ac8a267e8c5ae343c9f44 Mon Sep 17 00:00:00 2001 From: yuhuafeng Date: Mon, 27 Jul 2026 12:32:47 +0800 Subject: [PATCH 18/37] refactor(qwenpaw): migrate integrations to public plugin APIs --- .../teamharness/adapters/qwenpaw/plugin.json | 6 +- .../teamharness/adapters/qwenpaw/plugin.py | 1190 ++--------------- .../qwenpaw/scripts/build-qwenpaw-plugin.rb | 11 +- .../adapters/qwenpaw/test_adapter.py | 645 +-------- .../adapters/qwenpaw/test_package.py | 29 +- plugins/tests/teamharness/test_trace.py | 2 +- .../adapters/qwenpaw/test_adapter.py | 131 -- .../qwenpaw/test_workerflow_adapter.py | 36 + .../qwenpaw/test_workerflow_package.py | 2 +- plugins/workerflow/adapters/qwenpaw/plugin.py | 261 +--- .../qwenpaw/scripts/build-qwenpaw-plugin.rb | 4 +- qwenpaw/README.md | 78 +- 12 files changed, 323 insertions(+), 2072 deletions(-) delete mode 100644 plugins/tests/workerflow/adapters/qwenpaw/test_adapter.py create mode 100644 plugins/tests/workerflow/adapters/qwenpaw/test_workerflow_adapter.py diff --git a/plugins/teamharness/adapters/qwenpaw/plugin.json b/plugins/teamharness/adapters/qwenpaw/plugin.json index 759f4a104..2337d10a8 100644 --- a/plugins/teamharness/adapters/qwenpaw/plugin.json +++ b/plugins/teamharness/adapters/qwenpaw/plugin.json @@ -9,5 +9,9 @@ "backend": "plugin.py" }, "dependencies": [], - "min_version": "1.1.7" + "min_version": "2.0.1", + "qwenpaw_version": { + "min": "2.0.1", + "max": "2.1.0" + } } diff --git a/plugins/teamharness/adapters/qwenpaw/plugin.py b/plugins/teamharness/adapters/qwenpaw/plugin.py index 3cfa7d33b..48d6e2bc8 100644 --- a/plugins/teamharness/adapters/qwenpaw/plugin.py +++ b/plugins/teamharness/adapters/qwenpaw/plugin.py @@ -1,1099 +1,203 @@ -#!/usr/bin/env python3 -"""QwenPaw adapter for TeamHarness. - -The adapter owns QwenPaw-specific install/reconcile behavior. It does not poll -storage or runtime config by itself; the qwenpaw-worker triggers those loops. -""" +"""TeamHarness integration implemented with QwenPaw 2 public plugin APIs.""" from __future__ import annotations -import json -import logging +import importlib.util import os from pathlib import Path import re -import shutil -import sys -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, AsyncGenerator, Callable -logger = logging.getLogger(__name__) PLUGIN_DIR = Path(__file__).resolve().parent ASSET_DIR = PLUGIN_DIR / "teamharness" if not (ASSET_DIR / "plugin.yaml").exists(): ASSET_DIR = PLUGIN_DIR.parent.parent -TEAMS_PROMPT_FILE = "TEAMS.md" -TEAMS_INTERNAL_CONTROL_MARKER = ( - "" -) -MCP_CLIENT_ID = "teamharness" -REDACTION = "[REDACTED]" -SENSITIVE_FILE_AUTO_DENY_RULE = "SENSITIVE_FILE_BLOCK" -TEAMS_CONTEXT_START = "" -TEAMS_CONTEXT_END = "" -_TASK_TRACE_MODULE: Any = None -_TASK_TRACE_REGISTERED: bool = False - -_BUILTIN_SANITIZER_PATTERNS: List[tuple[re.Pattern[str], str]] = [ - (re.compile(r"\b(LTAI)[A-Za-z0-9]{12,}"), r"\1****"), - (re.compile(r"\b(AKIA)[A-Za-z0-9]{12,}"), r"\1****"), - (re.compile(r"\b(AKID)[A-Za-z0-9]{12,}"), r"\1****"), - ( - re.compile( - r"(?i)((?:access_?key_?secret|secret_?access_?key|accesskeysecret" - r"|TENCENTCLOUD_SECRET_KEY|aws_secret_access_key" - r"|credentials\.secret)" - r"""["']?[\s]*[=:]\s*["']?)([A-Za-z0-9/+=]{16,})""" - ), - r"\1********", - ), - ( - re.compile( - r"(?i)((?:security_?token|session_?token|sts_?token)" - r"""["']?[\s]*[=:]\s*["']?)([A-Za-z0-9/+=]{100,})""" - ), - r"\1********", - ), - ( - re.compile( - r"(?i)((?:secret|token|password|passwd|key_secret)" - r"""["']?[\s]*[=:]\s*["']?)([A-Za-z0-9/+=]{30,})""" - ), - r"\1********", - ), -] - -def _read_yaml(path: Path) -> Dict[str, Any]: - try: - import yaml - - data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - except FileNotFoundError: - return {} - except ImportError: - logger.warning("PyYAML unavailable; TeamHarness adapter cannot parse %s", path) - return {} - return data if isinstance(data, dict) else {} - - -def _runtime_config_path() -> Optional[Path]: - raw = os.getenv("TEAMHARNESS_RUNTIME_CONFIG", "").strip() - return Path(raw) if raw else None - - -def load_runtime_config() -> Dict[str, Any]: - path = _runtime_config_path() - return _read_yaml(path) if path else {} - - -def _section(data: Dict[str, Any], name: str) -> Dict[str, Any]: - value = data.get(name) or {} - return value if isinstance(value, dict) else {} - -def _string(value: Any) -> str: - return str(value).strip() if value is not None else "" - - -def _string_list(value: Any) -> List[str]: - if not isinstance(value, list): - return [] - return [_string(item) for item in value if _string(item)] - - -def _string_fields(value: Any, keys: List[str]) -> Dict[str, str]: - if not isinstance(value, dict): - return {} - result: Dict[str, str] = {} - for key in keys: - text = _string(value.get(key)) - if text: - result[key] = text - return result - - -def _read_json(path: Path) -> Dict[str, Any]: +def _team_prompt(_agent: Any) -> str: + path = ASSET_DIR / "prompts" / "team" / "TEAMS.md" try: - data = json.loads(path.read_text(encoding="utf-8")) - except (json.JSONDecodeError, OSError): - return {} - return data if isinstance(data, dict) else {} - - -def _write_json_atomic(path: Path, payload: Dict[str, Any]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_name(f".{path.name}.tmp") - tmp.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") - tmp.replace(path) - - -def _shared_dir() -> Path: - raw = os.getenv("TEAMHARNESS_SHARED_DIR", "").strip() or os.getenv("AGENTTEAMS_SHARED_DIR", "").strip() - if raw: - return Path(raw) - qwenpaw_dir = os.getenv("QWENPAW_WORKING_DIR", "").strip() - if qwenpaw_dir: - qwenpaw_path = Path(qwenpaw_dir) - if qwenpaw_path.name == ".qwenpaw": - return qwenpaw_path.parent.parent.parent / "shared" - return Path.cwd() / "shared" - - -def _role_prompt(role: str) -> Optional[Path]: - mapping = { - "leader": ASSET_DIR / "prompts" / "agent" / "leader.md", - "team_leader": ASSET_DIR / "prompts" / "agent" / "leader.md", - "worker": ASSET_DIR / "prompts" / "agent" / "worker.md", - "remote-member": ASSET_DIR / "prompts" / "agent" / "remote-member.md", - "manager": ASSET_DIR / "prompts" / "manager" / "AGENTS.md", - } - return mapping.get(role) - - -def render_team_context(config: Dict[str, Any]) -> str: - team = _section(config, "team") - member = _section(config, "member") - desired = _section(config, "desired") - package = _section(desired, "agentPackage") - channel_policy = _section(desired, "channelPolicy") - base_prompt = ASSET_DIR / "prompts" / "team" / "TEAMS.md" - base = base_prompt.read_text(encoding="utf-8").strip() if base_prompt.exists() else "" - role = _string(member.get("role") or os.getenv("AGENTTEAMS_AGENT_ROLE") or "worker") - role_prompt = _role_prompt(role) - role_text = role_prompt.read_text(encoding="utf-8").strip() if role_prompt and role_prompt.exists() else "" - - lines = [TEAMS_INTERNAL_CONTROL_MARKER, ""] - if base: - lines.append(base) - else: - lines.append("# Team Contract") - if role_text: - lines.extend(["", role_text]) - lines.extend(["", TEAMS_CONTEXT_START, "## Runtime Team Context", ""]) - - facts = [ - ("team.name", team.get("name")), - ("team.teamRoomId", team.get("teamRoomId")), - ("team.leaderName", team.get("leaderName")), - ("team.leaderRuntimeName", team.get("leaderRuntimeName")), - ("team.admin.name", _section(team, "admin").get("name")), - ("team.admin.matrixUserId", _section(team, "admin").get("matrixUserId")), - ("member.name", member.get("name")), - ("member.runtimeName", member.get("runtimeName")), - ("member.role", member.get("role")), - ("member.runtime", member.get("runtime")), - ("member.matrixUserId", member.get("matrixUserId")), - ("member.personalRoomId", member.get("personalRoomId")), - ("desired.agentPackage.name", package.get("name")), - ("desired.agentPackage.version", package.get("version")), - ] - for key, value in facts: - text = _string(value) - if text: - lines.append(f"- {key}: {text}") - members = team.get("members") - if isinstance(members, list) and members: - lines.extend(["", "### Team Members"]) - for item in members: - entry = _string_fields(item, ["name", "runtimeName", "role", "matrixUserId", "personalRoomId"]) - if entry: - lines.append("- " + ", ".join(f"{key}: {value}" for key, value in entry.items())) - - if channel_policy: - lines.append("- desired.channelPolicy: configured") - lines.append("") - lines.append("Do not write secrets, credentials, or live task status into this file.") - lines.append(TEAMS_CONTEXT_END) - return "\n".join(lines).rstrip() + "\n" - - -def _write_team_context(workspace_dir: Path, config: Dict[str, Any]) -> Dict[str, Any]: - target = workspace_dir / TEAMS_PROMPT_FILE - text = render_team_context(config) - target.parent.mkdir(parents=True, exist_ok=True) - existing = target.read_text(encoding="utf-8") if target.exists() else None - target.write_text(text, encoding="utf-8") - return { - "ok": True, - "asset": "team-context", - "path": str(target), - "action": "created" if existing is None else ("unchanged" if existing == text else "updated"), - } - - -def apply_team_context() -> Dict[str, Any]: - config = load_runtime_config() - targets = [ - {"agent": agent_id, **_write_team_context(workspace_dir, config)} - for agent_id, workspace_dir in _iter_qwenpaw_agents() - ] - return {"ok": True, "asset": "team-context", "targets": targets} - - -def _sanitizer_rules() -> List[str]: - config = load_runtime_config() - desired = _section(config, "desired") - policy = _section(desired, "outputSanitize") - rules = _string_list(policy.get("keywords")) - credentials = _section(config, "credentials") - env_refs = _string_list(policy.get("envRefs")) - for key in ("matrixTokenEnv", "gatewayKeyEnv", "storageAccessKeyEnv", "storageSecretKeyEnv"): - value = _string(credentials.get(key)) - if value: - env_refs.append(value) - for env_name in env_refs: - value = os.getenv(env_name, "") - if len(value) >= 4: - rules.append(value) - deduped = [] - seen = set() - for rule in rules: - if rule and rule not in seen: - seen.add(rule) - deduped.append(rule) - return deduped - - -def _qwenpaw_working_dir() -> Optional[Path]: - raw = os.getenv("QWENPAW_WORKING_DIR", "").strip() or os.getenv("COPAW_WORKING_DIR", "").strip() - if raw: - return Path(raw).expanduser() - home = os.getenv("HOME", "").strip() - return Path(home).expanduser() / ".qwenpaw" if home else None - - -def _credagent_paths() -> List[Path]: - paths: List[Path] = [] - for _agent_id, workspace_dir in _iter_qwenpaw_agents(): - paths.append(workspace_dir / "config" / "credagent.json") - for env_name in ("AGENTTEAMS_AGENT_HOME", "AGENTTEAMS_WORKER_HOME"): - raw = os.getenv(env_name, "").strip() - if raw: - paths.append(Path(raw).expanduser() / "config" / "credagent.json") - working_dir = _qwenpaw_working_dir() - if working_dir is not None: - paths.append(working_dir / "workspaces" / "default" / "config" / "credagent.json") - paths.append(working_dir.parent / "config" / "credagent.json") - - deduped: List[Path] = [] - seen = set() - for path in paths: - key = str(path) - if key not in seen: - seen.add(key) - deduped.append(path) - return deduped - - -def _credagent_specs() -> List[Dict[str, Any]]: - specs = [] - for path in _credagent_paths(): - if not path.exists(): - continue - spec = _read_json(path) - if spec: - specs.append(spec) - return specs - - -def _normalize_credentials(specs: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - normalized: List[Dict[str, Any]] = [] - for spec in specs: - credentials = spec.get("credentials", []) - if not isinstance(credentials, list): - continue - for entry in credentials: - if not isinstance(entry, dict): - continue - raw = _string(entry.get("path")) - if not raw: - continue - expanded = str(Path(raw).expanduser()) - if raw.endswith("/") and not expanded.endswith("/"): - expanded += "/" - permit = entry.get("programPermit", []) - if isinstance(permit, str): - permit = [permit] - elif not isinstance(permit, list): - permit = [] - normalized.append( - { - "path": expanded, - "programPermit": [str(item) for item in permit], - "writable": bool(entry.get("writable", False)), - } - ) - return normalized - - -def _credagent_output_sanitize_rules(specs: Optional[List[Dict[str, Any]]] = None) -> List[Dict[str, Any]]: - rules: List[Dict[str, Any]] = [] - for spec in specs if specs is not None else _credagent_specs(): - raw_rules = spec.get("output_sanitize", []) - if not isinstance(raw_rules, list): - continue - rules.extend(rule for rule in raw_rules if isinstance(rule, dict)) - return rules - - -def _sanitizer_patterns() -> List[tuple[re.Pattern[str], str]]: - patterns = list(_BUILTIN_SANITIZER_PATTERNS) - for rule in _credagent_output_sanitize_rules(): - try: - rule_type = rule.get("type", "") - if rule_type == "prefix": - prefix = str(rule["prefix"]) - min_length = int(rule.get("min_length", 16)) - suffix_len = max(min_length - len(prefix), 1) - escaped = re.escape(prefix) - patterns.append((re.compile(rf"\b({escaped})[A-Za-z0-9]{{{suffix_len},}}"), r"\1****")) - elif rule_type == "keyword": - keywords = rule.get("keywords", []) - if not isinstance(keywords, list) or not keywords: - continue - kw_alt = "|".join(re.escape(str(keyword)) for keyword in keywords) - patterns.append( - ( - re.compile(rf"""(?i)({kw_alt})(["']?[\s]*[=:]\s*["']?)([A-Za-z0-9/+=]{{16,}})"""), - r"\1\2********", - ) - ) - elif rule_type == "regex": - patterns.append((re.compile(str(rule["pattern"])), str(rule.get("replacement", "********")))) - except (KeyError, TypeError, ValueError, re.error): - continue - return patterns - - -def _sanitize_text_with_rules( - text: str, - exact_rules: List[str], - patterns: List[tuple[re.Pattern[str], str]], -) -> str: - sanitized = text - for rule in sorted(exact_rules, key=len, reverse=True): - sanitized = re.sub(re.escape(rule), REDACTION, sanitized) - for pattern, replacement in patterns: - sanitized = pattern.sub(replacement, sanitized) - return sanitized - - -def sanitize_text(text: str) -> str: - return _sanitize_text_with_rules(text, _sanitizer_rules(), _sanitizer_patterns()) - - -def _qwenpaw_config_path() -> Optional[Path]: - working_dir = _qwenpaw_working_dir() - if working_dir is not None and os.getenv("QWENPAW_WORKING_DIR", "").strip(): - return working_dir / "config.json" - try: - from qwenpaw.config import get_config_path + return path.read_text(encoding="utf-8").strip() + except OSError: + return "" - return Path(get_config_path()) - except Exception: - return working_dir / "config.json" if working_dir is not None else None +def _sanitizer_rules() -> list[str]: + raw = os.getenv("AGENTTEAMS_OUTPUT_SANITIZE_KEYWORDS", "") + return [value.strip() for value in raw.split(",") if value.strip()] -def _reload_qwenpaw_file_guard() -> None: - try: - from qwenpaw.security.tool_guard.engine import get_guard_engine - get_guard_engine().reload_rules() - except Exception: +def _sanitize(value: Any) -> None: + rules = _sanitizer_rules() + if not rules: return - - -def apply_credential_guard() -> Dict[str, Any]: - specs = _credagent_specs() - if not specs: - return { - "ok": True, - "hook": "credential-guard", - "applied": False, - "credentials": 0, - "outputSanitizeRules": 0, - "reason": "credagent.json not found", - } - - credentials = _normalize_credentials(specs) - paths = {entry["path"] for entry in credentials} - rules_count = len(_credagent_output_sanitize_rules(specs)) - config_path = _qwenpaw_config_path() - if config_path is None: - return { - "ok": True, - "hook": "credential-guard", - "applied": False, - "credentials": len(credentials), - "outputSanitizeRules": rules_count, - "reason": "qwenpaw config path unavailable", - } - - config = _read_json(config_path) - security = config.setdefault("security", {}) - plugins = config.setdefault("plugins", {}) - teamharness = plugins.setdefault("teamharness", {}) - guard_state = teamharness.setdefault("credentialGuard", {}) - previous_paths = { - str(path) - for path in (guard_state.get("paths", []) or []) - if str(path) - } - - file_guard = security.setdefault("file_guard", {}) - file_guard["enabled"] = True - existing = { - str(path) - for path in (file_guard.get("sensitive_files", []) or []) - if str(path) - } - previous_paths - existing.update(paths) - file_guard["sensitive_files"] = sorted(existing) - - tool_guard = security.setdefault("tool_guard", {}) - tool_guard["enabled"] = True - auto_rules = [ - str(rule) - for rule in (tool_guard.get("auto_denied_rules", []) or []) - if str(rule) - ] - if SENSITIVE_FILE_AUTO_DENY_RULE not in auto_rules: - auto_rules.append(SENSITIVE_FILE_AUTO_DENY_RULE) - tool_guard["auto_denied_rules"] = auto_rules - - guard_state["paths"] = sorted(paths) - guard_state["source"] = "credagent.json" - _write_json_atomic(config_path, config) - _reload_qwenpaw_file_guard() - return { - "ok": True, - "hook": "credential-guard", - "applied": True, - "credentials": len(credentials), - "outputSanitizeRules": rules_count, - "runtimeConfig": str(config_path), - "reason": None, - } - - -def sanitize_tool_result(result: Any) -> Dict[str, Any]: - summary = {"ok": True, "hook": "output-sanitizer", "redacted": False, "blocks": 0} - exact_rules = _sanitizer_rules() - patterns = _sanitizer_patterns() - if isinstance(result, dict): - content = result.get("content") or result.get("output") - else: - content = getattr(result, "content", None) or getattr(result, "output", None) - if isinstance(content, str): - sanitized = _sanitize_text_with_rules(content, exact_rules, patterns) - if sanitized != content: - if isinstance(result, dict): - result["content"] = sanitized + if isinstance(value, dict): + for key, item in list(value.items()): + if isinstance(item, str): + for rule in rules: + item = re.sub( + re.escape(rule), + "[REDACTED]", + item, + flags=re.IGNORECASE, + ) + value[key] = item else: - setattr(result, "content", sanitized) - summary["redacted"] = True - summary["blocks"] = 1 - return summary - if not isinstance(content, list): - return summary - - for block in content: - if isinstance(block, dict): - text = block.get("text") - else: - text = getattr(block, "text", None) - if not isinstance(text, str): - continue - summary["blocks"] += 1 - sanitized = _sanitize_text_with_rules(text, exact_rules, patterns) - if sanitized != text: - if isinstance(block, dict): - block["text"] = sanitized + _sanitize(item) + return + if isinstance(value, list): + for item in value: + _sanitize(item) + return + for attr in ("content", "output", "text"): + if hasattr(value, attr): + item = getattr(value, attr) + if isinstance(item, str): + for rule in rules: + item = re.sub( + re.escape(rule), + "[REDACTED]", + item, + flags=re.IGNORECASE, + ) + setattr(value, attr, item) else: - setattr(block, "text", sanitized) - summary["redacted"] = True - return summary - - -def _iter_qwenpaw_agents() -> List[tuple[str, Path]]: - workspace = os.getenv("QWENPAW_WORKSPACE_DIR", "").strip() - if workspace: - return [("default", Path(workspace).expanduser())] - try: - from qwenpaw.config.utils import load_config - except ImportError: - qwenpaw_dir = os.getenv("QWENPAW_WORKING_DIR", "").strip() - workspace = str(Path(qwenpaw_dir) / "workspaces" / "default") if qwenpaw_dir else "" - return [("default", Path(workspace))] if workspace else [] - - root = load_config() - profiles = getattr(getattr(root, "agents", None), "profiles", {}) or {} - agents = [] - for agent_id, ref in profiles.items(): - if getattr(ref, "enabled", True) is False: - continue - workspace_dir = Path(getattr(ref, "workspace_dir", "")).expanduser() - if str(workspace_dir): - agents.append((str(agent_id), workspace_dir)) - return agents - - -def _copytree_replace(source: Path, target: Path) -> None: - if target.exists(): - shutil.rmtree(target) - shutil.copytree(source, target, ignore=shutil.ignore_patterns("__pycache__", ".DS_Store", "*.pyc")) - - -def _skill_entries() -> List[Dict[str, Any]]: - manifest = _read_yaml(ASSET_DIR / "plugin.yaml") - skills = _section(manifest, "skills") - entries = [] - for group in ("agent", "team"): - for entry in skills.get(group) or []: - if isinstance(entry, dict): - entries.append(entry) - return entries - - -def _role_aliases(role: str) -> set[str]: - normalized = _string(role) or "worker" - aliases = {normalized} - if normalized == "team_leader": - aliases.add("leader") - if normalized == "remote_member": - aliases.add("remote-member") - return aliases - - -def _skill_names_for_role(role: str) -> List[str]: - role_aliases = _role_aliases(role) - names = [] - for entry in _skill_entries(): - skill_id = _string(entry.get("id")) - if not skill_id: - continue - roles = _string_list(entry.get("roles")) - if roles and not role_aliases.intersection(roles): - continue - names.append(f"teamharness-{skill_id}") - return names - - -def _enable_workspace_skills(workspace_dir: Path, role: str) -> Dict[str, Any]: - installed = [] - failed = [] - try: - from qwenpaw.agents.skill_system.pool_service import SkillPoolService - except ImportError: - return {"installed": installed, "skipped": "qwenpaw skill pool API unavailable"} - - service = SkillPoolService() - for skill_name in _skill_names_for_role(role): - result = service.download_to_workspace(skill_name, workspace_dir, overwrite=True) - if result.get("success"): - installed.append(skill_name) - else: - failed.append({"name": skill_name, "reason": result.get("reason") or "unknown"}) - return {"installed": installed, "failed": failed} - + _sanitize(item) -def _install_workspace_assets(agent_id: str, workspace_dir: Path, config: Dict[str, Any]) -> Dict[str, Any]: - member = _section(config, "member") - role = _string(member.get("role") or os.getenv("AGENTTEAMS_AGENT_ROLE") or "worker") - workspace_dir.mkdir(parents=True, exist_ok=True) - team_context = _write_team_context(workspace_dir, config) - prompts = _ensure_prompt_files(agent_id, [TEAMS_PROMPT_FILE]) - skills = _enable_workspace_skills(workspace_dir, role) - return { - "agent": agent_id, - "workspace": str(workspace_dir), - "role": role, - "teamContext": team_context, - "prompts": prompts, - "skills": skills, - } - -def _ensure_prompt_files(agent_id: str, prompt_files: List[str]) -> Dict[str, Any]: - try: - from qwenpaw.config.config import load_agent_config, save_agent_config - except ImportError: - return {"agent": agent_id, "skipped": "qwenpaw config API unavailable"} - - agent_config = load_agent_config(agent_id) - existing = list(agent_config.system_prompt_files or []) - changed = False - for prompt_file in prompt_files: - if prompt_file not in existing: - existing.append(prompt_file) - changed = True - if changed: - agent_config.system_prompt_files = existing - save_agent_config(agent_id, agent_config) - return {"agent": agent_id, "files": existing, "action": "updated" if changed else "unchanged"} - - -def _install_skills() -> Dict[str, Any]: - installed = [] - try: - from qwenpaw.agents.skill_system.registry import ensure_skill_pool_initialized, reconcile_pool_manifest - from qwenpaw.agents.skill_system.store import get_skill_pool_dir - except ImportError: - return {"installed": installed, "skipped": "qwenpaw skill API unavailable"} - - ensure_skill_pool_initialized() - pool_dir = get_skill_pool_dir() - pool_dir.mkdir(parents=True, exist_ok=True) - for entry in _skill_entries(): - source = ASSET_DIR / str(entry.get("path") or "") - skill_id = _string(entry.get("id")) - if not skill_id or not source.exists(): - continue - target = pool_dir / f"teamharness-{skill_id}" - _copytree_replace(source, target) - installed.append(target.name) - reconcile_pool_manifest() - return {"installed": installed} - - -def _mcp_client_env() -> Dict[str, str]: - env = { - "TEAMHARNESS_SHARED_DIR": str(_shared_dir()), - "LOONGSUITE_PYTHON_SITE_BOOTSTRAP_LOG_SUCCESS": "false", - } - for name in ( - "TEAMHARNESS_RUNTIME_CONFIG", - "AGENTTEAMS_MATRIX_URL", - "AGENTTEAMS_WORKER_MATRIX_TOKEN", - "AGENTTEAMS_MATRIX_USER_ID", - "AGENTTEAMS_WORKER_ROLE", - "AGENTTEAMS_AGENT_ROLE", - "AGENTTEAMS_WORKER_NAME", - "AGENTTEAMS_STORAGE_PREFIX", - "AGENTTEAMS_SHARED_STORAGE_PREFIX", - "AGENTTEAMS_FS_BUCKET", - "AGENTTEAMS_CONTROLLER_URL", - "AGENTTEAMS_AUTH_TOKEN_FILE", - "AGENTTEAMS_FS_ENDPOINT", - "QWENPAW_WORKING_DIR", - "COPAW_WORKING_DIR", - ): - value = os.getenv(name, "").strip() - if value: - env[name] = value - return env - - -def _ensure_mcp_client(agent_id: str, workspace_dir: Path) -> Dict[str, Any]: +def _sanitizer_factory(_ctx: Any, _agent_config: Any): try: - from qwenpaw.config.config import MCPClientConfig, MCPConfig, load_agent_config, save_agent_config + from agentscope.middleware import MiddlewareBase except ImportError: - marker = workspace_dir / ".teamharness" / "mcp-client.json" - marker.parent.mkdir(parents=True, exist_ok=True) - marker.write_text(json.dumps({"id": MCP_CLIENT_ID}, indent=2), encoding="utf-8") - return {"agent": agent_id, "action": "marker", "path": str(marker)} - - agent_config = load_agent_config(agent_id) - if agent_config.mcp is None: - agent_config.mcp = MCPConfig() - server_path = ASSET_DIR / "mcp" / "server.py" - client = MCPClientConfig( - name=MCP_CLIENT_ID, - description="TeamHarness collaboration MCP server", - enabled=True, - transport="stdio", - command=sys.executable, - args=[str(server_path)], - cwd=str(ASSET_DIR), - env=_mcp_client_env(), + return None + + class TeamHarnessSanitizer(MiddlewareBase): + async def on_acting( + self, + _agent: Any, + input_kwargs: dict[str, Any], + next_handler: Callable[..., AsyncGenerator[Any, None]], + ) -> AsyncGenerator[Any, None]: + async for item in next_handler(**input_kwargs): + _sanitize(item) + yield item + + return TeamHarnessSanitizer() + + +def _load_trace_module() -> Any | None: + path = PLUGIN_DIR / "task_trace.py" + if not path.is_file(): + return None + spec = importlib.util.spec_from_file_location( + "agentteams_teamharness_task_trace", + path, ) - agent_config.mcp.clients[MCP_CLIENT_ID] = client - save_agent_config(agent_id, agent_config) - return {"agent": agent_id, "action": "configured"} - - -def apply_teamharness() -> Dict[str, Any]: - config = load_runtime_config() - credential_guard = apply_credential_guard() - skills = _install_skills() - agents = [] - mcp = [] - for agent_id, workspace_dir in _iter_qwenpaw_agents(): - agents.append(_install_workspace_assets(agent_id, workspace_dir, config)) - mcp.append(_ensure_mcp_client(agent_id, workspace_dir)) - return {"ok": True, "agents": agents, "skills": skills, "mcp": mcp, "credentialGuard": credential_guard} - - -def _allow_persisted_internal_mcp_policy() -> None: - from qwenpaw.constant import WORKING_DIR - from qwenpaw.drivers.storage import card_paths_for_name, dump_card, load_card - - workspaces_dir = WORKING_DIR / "workspaces" - if not workspaces_dir.is_dir(): - return - for workspace_dir in workspaces_dir.iterdir(): - if not workspace_dir.is_dir(): - continue - cards_dir = workspace_dir / "drivers" - for path in card_paths_for_name(cards_dir, MCP_CLIENT_ID): - card = load_card(path) - if card.protocol != "mcp": - continue - if card.policy.default_effect == "allow" and not card.policy.rules: - continue - card.policy.default_effect = "allow" - card.policy.rules = [] - dump_card(card, path) - - -def install_internal_mcp_allow_policy() -> Dict[str, Any]: - try: - from qwenpaw.drivers.adapters import mcp_legacy_config - except ImportError: - return {"ok": True, "installed": False, "reason": "qwenpaw driver API unavailable"} - - allowed_clients = getattr(mcp_legacy_config, "_agentteams_allowed_mcp_clients", set()) - allowed_clients.add(MCP_CLIENT_ID) - mcp_legacy_config._agentteams_allowed_mcp_clients = allowed_clients - _allow_persisted_internal_mcp_policy() - if getattr(mcp_legacy_config, "_agentteams_policy_wrapper_installed", False): - return {"ok": True, "installed": True, "action": "updated"} - - original = mcp_legacy_config.legacy_mcp_client_to_driver - - def _legacy_mcp_client_to_driver(client_key, config): - card, credential = original(client_key, config) - if client_key in mcp_legacy_config._agentteams_allowed_mcp_clients: - card.policy.default_effect = "allow" - card.policy.rules = [] - return card, credential - - mcp_legacy_config.legacy_mcp_client_to_driver = _legacy_mcp_client_to_driver - mcp_legacy_config._agentteams_policy_wrapper_installed = True - return {"ok": True, "installed": True, "action": "created"} - - -def install_output_sanitizer_wrapper(api: Any = None) -> Dict[str, Any]: - try: - from qwenpaw.agents.react_agent import QwenPawAgent - except ImportError: - return {"ok": True, "installed": False, "reason": "qwenpaw agent API unavailable"} - if getattr(QwenPawAgent, "_teamharness_sanitizer_installed", False): - return {"ok": True, "installed": True, "action": "unchanged"} - - original = QwenPawAgent._acting - - async def _acting_with_sanitizer(self, tool_call): - async for chunk in original(self, tool_call): - sanitize_tool_result(chunk) - yield chunk - - QwenPawAgent._acting = _acting_with_sanitizer - QwenPawAgent._teamharness_sanitizer_installed = True - return {"ok": True, "installed": True, "action": "created"} - - -def _task_trace_module_path() -> Optional[Path]: - candidates = [ - PLUGIN_DIR / "task_trace.py", - ] - seen = set() - for candidate in candidates: - if candidate in seen: - continue - seen.add(candidate) - if candidate.exists(): - return candidate - return None - - -def _load_task_trace_module() -> Tuple[Optional[Any], Optional[str]]: - global _TASK_TRACE_MODULE - if _TASK_TRACE_MODULE is not None: - return _TASK_TRACE_MODULE, None - - trace_module_path = _task_trace_module_path() - if trace_module_path is None: - return None, "task_trace.py not found" - - try: - import importlib.util - - spec = importlib.util.spec_from_file_location("teamharness_qwenpaw_task_trace", trace_module_path) - if spec is None or spec.loader is None: - return None, "cannot load trace module" - module = importlib.util.module_from_spec(spec) - sys.modules["teamharness_qwenpaw_task_trace"] = module - spec.loader.exec_module(module) - except Exception as exc: - sys.modules.pop("teamharness_qwenpaw_task_trace", None) - return None, str(exc) - - _TASK_TRACE_MODULE = module - return module, None - - -def _room_id_from_request_context(context: Any) -> str: - if not isinstance(context, dict): - return "" - for key in ("room_id", "roomId"): - room_id = _string(context.get(key)) - if room_id: - return room_id - meta = context.get("channel_meta") - if isinstance(meta, dict): - room_id = _string(meta.get("room_id") or meta.get("roomId")) - if room_id: - return room_id - session_id = _string(context.get("session_id")) - if session_id.startswith("matrix:"): - return session_id[len("matrix:") :] - if _string(context.get("channel")) == "matrix": - return _string(context.get("user_id")) + if spec is None or spec.loader is None: + return None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _room_id(request: Any) -> str: + if hasattr(request, "model_dump"): + data = request.model_dump() + elif hasattr(request, "__dict__"): + data = vars(request) + else: + data = {} + meta = data.get("channel_meta") or data.get("meta") or {} + room = str(meta.get("room_id") or meta.get("roomId") or "") if isinstance(meta, dict) else "" + if room: + return room + session = str(data.get("session_id") or "") + for prefix in ("agentteams_matrix:", "matrix:"): + if session.startswith(prefix): + return session[len(prefix):] return "" -def _task_trace_debug_enabled() -> bool: - return os.getenv("AGENTTEAMS_TASK_TRACE_DEBUG", "").strip().lower() in {"1", "true", "yes"} - - -def _canonical_task_trace_room(trace_module: Any, room_id: str) -> str: - canonicalize = getattr(trace_module, "canonical_room_id", None) - if callable(canonicalize): - try: - return str(canonicalize(room_id) or "").strip() - except Exception: - return _string(room_id) - return _string(room_id) - - -def _refresh_shared_tasks_for_task_trace(trace_module: Any, room_id: str) -> Dict[str, Any]: - """Best-effort pull of shared task metadata before tagging a turn span.""" - room = _canonical_task_trace_room(trace_module, room_id) - if not room: - return {"ok": True, "action": "skipped", "reason": "missing room"} - - qwenpaw_dir = _qwenpaw_working_dir() - if qwenpaw_dir is None: - return {"ok": False, "reason": "workspace dir unavailable"} - - shared_dir = _shared_dir() - shared_prefix = os.getenv("AGENTTEAMS_SHARED_STORAGE_PREFIX", "").strip().strip("/") or "shared" - remote_prefix = f"{shared_prefix}/tasks" - local_tasks_dir = shared_dir / "tasks" - worker_name = ( - os.getenv("AGENTTEAMS_WORKER_NAME", "").strip() - or os.getenv("AGENTTEAMS_AGENT_NAME", "").strip() - or "qwenpaw" - ) - - try: - from qwenpaw_worker.sync import FileSync - - sync = FileSync( - endpoint=os.getenv("AGENTTEAMS_FS_ENDPOINT", ""), - access_key=os.getenv("AGENTTEAMS_FS_ACCESS_KEY", ""), - secret_key=os.getenv("AGENTTEAMS_FS_SECRET_KEY", ""), - bucket=os.getenv("AGENTTEAMS_FS_BUCKET", "agentteams-storage"), - worker_name=worker_name, - local_dir=qwenpaw_dir.parent, - shared_dir=shared_dir, - shared_prefix=shared_prefix, - ) - sync.mirror_prefix(remote_prefix, local_tasks_dir) - except Exception as exc: - if _task_trace_debug_enabled(): - logger.warning( - "AgentTeamsTaskSpanProcessor shared tasks refresh failed room=%s remote=%s local=%s error_type=%s error=%s", - room, - remote_prefix, - local_tasks_dir, - type(exc).__name__, - exc, - ) - return {"ok": False, "reason": str(exc), "remote": remote_prefix} - - if _task_trace_debug_enabled(): - logger.info( - "AgentTeamsTaskSpanProcessor shared tasks refreshed room=%s remote=%s local=%s", - room, - remote_prefix, - local_tasks_dir, - ) - return {"ok": True, "action": "refreshed", "remote": remote_prefix, "local": str(local_tasks_dir)} - - -def install_task_trace_context_wrapper(trace_module: Any) -> Dict[str, Any]: +def _register_trace_hooks(api: Any) -> None: try: - from qwenpaw.agents.react_agent import QwenPawAgent + from qwenpaw.runtime.hooks import HookBase, HookResult + from qwenpaw.runtime.phases import Phase except ImportError: - return {"ok": True, "installed": False, "reason": "qwenpaw agent API unavailable"} - if getattr(QwenPawAgent, "_teamharness_trace_context_installed", False): - return {"ok": True, "installed": True, "action": "unchanged"} - - original_call = getattr(QwenPawAgent, "__call__", None) - original_reply = getattr(QwenPawAgent, "reply", None) - if not callable(original_call) or not callable(original_reply): - return {"ok": False, "installed": False, "reason": "qwenpaw agent call API unavailable"} - - def _set_room_context(agent: Any): - room_id = _room_id_from_request_context(getattr(agent, "_request_context", {}) or {}) - return (trace_module.set_current_room(room_id) if room_id else None, room_id) - - def _tag_current_entry_span() -> None: - workspace_dir = _qwenpaw_working_dir() - if workspace_dir is None: - return - default_ws = workspace_dir / "workspaces" / "default" - # Prefer the pending entry span stashed by on_start (the common path: - # entry span was created before room context was available). - tag_pending = getattr(trace_module, "tag_pending_entry_span", None) - if callable(tag_pending): - get_pending = getattr(trace_module, "get_pending_entry_span", None) - if callable(get_pending) and get_pending() is not None: - tag_pending(default_ws) - return - # Fallback: current span might be the entry span (e.g. room was set - # before instrumentation created it). - tag_current = getattr(trace_module, "tag_current_entry_span", None) - if callable(tag_current): - tag_current(default_ws) - - async def _call_with_task_trace_context(self, *args, **kwargs): - token, room_id = _set_room_context(self) - try: - _refresh_shared_tasks_for_task_trace(trace_module, room_id) - _tag_current_entry_span() - _retry_task_trace_registration(trace_module) - return await original_call(self, *args, **kwargs) - finally: - if token is not None: - trace_module.reset_current_room(token) - - async def _reply_with_task_trace_context(self, *args, **kwargs): - token, room_id = _set_room_context(self) - try: - _refresh_shared_tasks_for_task_trace(trace_module, room_id) - _tag_current_entry_span() - _retry_task_trace_registration(trace_module) - return await original_reply(self, *args, **kwargs) - finally: - if token is not None: - trace_module.reset_current_room(token) - - QwenPawAgent.__call__ = _call_with_task_trace_context - QwenPawAgent.reply = _reply_with_task_trace_context - QwenPawAgent._teamharness_trace_context_installed = True - QwenPawAgent._teamharness_trace_original_call = original_call - QwenPawAgent._teamharness_trace_original_reply = original_reply - return {"ok": True, "installed": True, "action": "created"} - - -def _retry_task_trace_registration(trace_module: Any) -> None: - if _TASK_TRACE_REGISTERED: - return - workspace_dir = _qwenpaw_working_dir() - if workspace_dir is None: return - result = _register_task_trace_processor(trace_module, workspace_dir / "workspaces" / "default") - if result.get("ok"): + trace = _load_trace_module() + if trace is None: return - reason = str(result.get("reason") or "unknown") - logger.info("AgentTeamsTaskSpanProcessor lazy registration skipped: %s", reason) - - -def _register_task_trace_processor(trace_module: Any, workspace_dir: Path) -> Dict[str, Any]: - global _TASK_TRACE_REGISTERED - if _TASK_TRACE_REGISTERED: - return {"ok": True, "processor": "AgentTeamsTaskSpanProcessor", "action": "unchanged"} - result = trace_module.register_task_trace_processor(workspace_dir) - if isinstance(result, dict) and result.get("ok"): - _TASK_TRACE_REGISTERED = True - return result if isinstance(result, dict) else {"ok": False, "reason": "invalid registration result"} - - -def install_task_trace_processor() -> Dict[str, Any]: - """Register the AgentTeams Task-Trace SpanProcessor on the active TracerProvider.""" - workspace_dir = _qwenpaw_working_dir() - if workspace_dir is None: - return {"ok": False, "reason": "workspace dir unavailable"} - default_workspace = workspace_dir / "workspaces" / "default" + workspace = Path(os.getenv("QWENPAW_WORKING_DIR", ".")) / "workspaces" / "default" + trace.register_task_trace_processor(workspace) + + class TraceEnter(HookBase): + phase = Phase.PRE_DISPATCH + name = "teamharness_trace_enter" + priority = 20 + + async def run(self, ctx: Any) -> Any: + room = _room_id(ctx.request) + if room: + ctx.extras["teamharness_trace_token"] = trace.set_current_room(room) + return HookResult() + + class TraceExit(HookBase): + phase = Phase.FINALLY + name = "teamharness_trace_exit" + priority = 200 + + async def run(self, ctx: Any) -> Any: + token = ctx.extras.pop("teamharness_trace_token", None) + if token is not None: + trace.reset_current_room(token) + return HookResult() - module, error = _load_task_trace_module() - if module is None: - return {"ok": False, "reason": error or "cannot load trace module"} - - try: - context_result = install_task_trace_context_wrapper(module) - result = _register_task_trace_processor(module, default_workspace) - if isinstance(result, dict): - result = dict(result) - result["context"] = context_result - if not result.get("ok"): - logger.warning( - "AgentTeamsTaskSpanProcessor not registered: %s", - result.get("reason") or "unknown", - ) - return result - except Exception as exc: - logger.warning("Failed to register AgentTeamsTaskSpanProcessor: %s", exc) - return {"ok": False, "reason": str(exc)} + api.register_runtime_hook(TraceEnter()) + api.register_runtime_hook(TraceExit()) class TeamHarnessPlugin: - def __init__(self) -> None: - self.last_apply_result: Dict[str, Any] = {} - self.policy_result: Dict[str, Any] = {} - self.sanitizer_result: Dict[str, Any] = {} - self.trace_result: Dict[str, Any] = {} - - def _sync_runtime(self, api: Any) -> Dict[str, Any]: - self.policy_result = install_internal_mcp_allow_policy() - self.last_apply_result = apply_teamharness() - self.last_apply_result["driverPolicy"] = self.policy_result - self.sanitizer_result = install_output_sanitizer_wrapper(api=api) - self.trace_result = install_task_trace_processor() - return self.last_apply_result - def register(self, api: Any) -> None: - def sync() -> Dict[str, Any]: - return self._sync_runtime(api) - - def shutdown() -> None: - return None - - api.register_startup_hook("teamharness_sync", sync, priority=40) - api.register_shutdown_hook("teamharness_shutdown", shutdown, priority=100) + api.register_prompt_section( + "teamharness_context", + after="workspace", + provider=_team_prompt, + priority=40, + ) + api.register_skill_provider( + ASSET_DIR / "qwenpaw-skills", + enabled_by_default=True, + channels=["all"], + ) + api.register_middleware(_sanitizer_factory, priority=30) + _register_trace_hooks(api) self._register_http(api) def _register_http(self, api: Any) -> None: try: from fastapi import APIRouter - except Exception: + except ImportError: return router = APIRouter() @router.get("/health") - def health() -> Dict[str, Any]: - return {"ok": True, "plugin": "teamharness", "adapter": "qwenpaw"} + def health() -> dict[str, Any]: + return {"ok": True, "plugin": "teamharness", "adapter": "qwenpaw-2"} - @router.get("/status") - def status() -> Dict[str, Any]: + @router.post("/sync") + def sync_endpoint() -> dict[str, Any]: return { "ok": True, - "lastApply": self.last_apply_result, - "driverPolicy": self.policy_result, - "sanitizer": self.sanitizer_result, - "trace": self.trace_result, + "plugin": "teamharness", + "managedBy": "qwenpaw-plugin-api", } - @router.post("/sync") - def sync_endpoint() -> Dict[str, Any]: - return self._sync_runtime(api) - - api.register_http_router(router, prefix="/teamharness", tags=["teamharness"]) + api.register_http_router( + router, + prefix="/teamharness", + tags=["teamharness"], + ) plugin = TeamHarnessPlugin() diff --git a/plugins/teamharness/adapters/qwenpaw/scripts/build-qwenpaw-plugin.rb b/plugins/teamharness/adapters/qwenpaw/scripts/build-qwenpaw-plugin.rb index 8226b691a..7fab1e65b 100755 --- a/plugins/teamharness/adapters/qwenpaw/scripts/build-qwenpaw-plugin.rb +++ b/plugins/teamharness/adapters/qwenpaw/scripts/build-qwenpaw-plugin.rb @@ -91,6 +91,13 @@ def zip_dir(root, package_name, out_path) ].each do |entry| copy_entry(plugin_root, asset_dir, entry) end + public_skills = asset_dir / "qwenpaw-skills" + public_skills.mkpath + %w[agent team].each do |group| + Dir.glob((plugin_root / "skills" / group / "*").to_s).each do |skill| + FileUtils.cp_r(skill, public_skills) + end + end copy_entry(adapter_root, staging, "plugin.py") copy_entry(adapter_root, staging, "task_trace.py") @@ -106,9 +113,9 @@ def zip_dir(root, package_name, out_path) "backend" => "plugin.py" }, "dependencies" => [], - "min_version" => "1.1.7", + "min_version" => "2.0.1", "qwenpaw_version" => { - "min" => "2.0.0", + "min" => "2.0.1", "max" => "2.1.0" }, "meta" => { diff --git a/plugins/tests/teamharness/adapters/qwenpaw/test_adapter.py b/plugins/tests/teamharness/adapters/qwenpaw/test_adapter.py index b687a9092..da505e310 100644 --- a/plugins/tests/teamharness/adapters/qwenpaw/test_adapter.py +++ b/plugins/tests/teamharness/adapters/qwenpaw/test_adapter.py @@ -1,613 +1,82 @@ -import asyncio +"""QwenPaw 2 public plugin API contract tests for TeamHarness.""" + import importlib.util -import json -import os from pathlib import Path -import shutil import sys import types -import pytest - - -REPO_ROOT = Path(__file__).resolve().parents[5] -PLUGIN = REPO_ROOT / "plugins" / "teamharness" / "adapters" / "qwenpaw" / "plugin.py" +ROOT = Path(__file__).resolve().parents[5] +PLUGIN_PATH = ROOT / "plugins" / "teamharness" / "adapters" / "qwenpaw" / "plugin.py" -@pytest.fixture(autouse=True) -def _isolate_qwenpaw_runtime(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - qwenpaw_modules = { - name: module - for name, module in sys.modules.items() - if name == "qwenpaw" or name.startswith("qwenpaw.") - } - for name in qwenpaw_modules: - sys.modules.pop(name, None) - original = os.environ.pop("AGENT_WORKSPACE", None) - monkeypatch.setenv("QWENPAW_WORKING_DIR", str(tmp_path / ".qwenpaw")) - try: - yield - finally: - for name in tuple(sys.modules): - if name == "qwenpaw" or name.startswith("qwenpaw."): - sys.modules.pop(name, None) - sys.modules.update(qwenpaw_modules) - if original is None: - os.environ.pop("AGENT_WORKSPACE", None) - else: - os.environ["AGENT_WORKSPACE"] = original - -def _load_plugin(): - spec = importlib.util.spec_from_file_location("teamharness_qwenpaw_plugin_test", PLUGIN) +def load_plugin(): + spec = importlib.util.spec_from_file_location( + "teamharness_qwenpaw_plugin_test", + PLUGIN_PATH, + ) module = importlib.util.module_from_spec(spec) - assert spec.loader is not None spec.loader.exec_module(module) return module -def _runtime_yaml(path: Path) -> None: - path.write_text( - """ -kind: MemberRuntimeConfig -metadata: - generation: 3 -team: - name: demo-team - teamRoomId: "!team:matrix.local" - leaderName: leader - leaderRuntimeName: leader-runtime - admin: - name: admin - matrixUserId: "@admin:matrix.local" - members: - - name: leader - runtimeName: leader-runtime - role: team_leader - matrixUserId: "@leader-runtime:matrix.local" - personalRoomId: "!leader-dm:matrix.local" - - name: worker-a - runtimeName: worker-a - role: worker - matrixUserId: "@worker-a:matrix.local" - personalRoomId: "!worker-dm:matrix.local" - - name: alpha-qa - runtimeName: qa-runtime - role: worker - matrixUserId: "@qa-runtime:matrix.local" -member: - name: worker-a - runtimeName: worker-a - role: worker - runtime: qwenpaw - matrixUserId: "@worker-a:matrix.local" - personalRoomId: "!worker-dm:matrix.local" -desired: - agentPackage: - name: dev-worker - version: 1.2.0 - outputSanitize: - keywords: [internal-token] - envRefs: [EXTRA_SECRET] -credentials: - matrixTokenEnv: AGENTTEAMS_WORKER_MATRIX_TOKEN -""", - encoding="utf-8", - ) - - -def _leader_runtime_yaml(path: Path) -> None: - path.write_text( - """ -kind: MemberRuntimeConfig -metadata: - generation: 4 -team: - name: demo-team - teamRoomId: "!team:matrix.local" -member: - name: leader-a - runtimeName: leader-a - role: team_leader - runtime: qwenpaw -""", - encoding="utf-8", - ) - - -def test_mcp_client_env_includes_sts_refresh_inputs(monkeypatch, tmp_path: Path) -> None: - module = _load_plugin() - shared_dir = tmp_path / "shared" - monkeypatch.setenv("TEAMHARNESS_SHARED_DIR", str(shared_dir)) - monkeypatch.setenv("AGENTTEAMS_CONTROLLER_URL", "http://controller.example.test") - monkeypatch.setenv("AGENTTEAMS_AUTH_TOKEN_FILE", "/var/run/secrets/agentteams/token") - monkeypatch.setenv("AGENTTEAMS_FS_ENDPOINT", "https://oss.example.test") - monkeypatch.setenv("AGENTTEAMS_FS_ACCESS_KEY", "static-ak") - monkeypatch.setenv("AGENTTEAMS_FS_SECRET_KEY", "static-sk") - monkeypatch.setenv("MC_HOST_agentteams", "https://temporary@example.test") - monkeypatch.setenv("QWENPAW_WORKING_DIR", str(tmp_path / ".qwenpaw")) - - env = module._mcp_client_env() - - assert env["TEAMHARNESS_SHARED_DIR"] == str(shared_dir) - assert env["AGENTTEAMS_CONTROLLER_URL"] == "http://controller.example.test" - assert env["AGENTTEAMS_AUTH_TOKEN_FILE"] == "/var/run/secrets/agentteams/token" - assert env["AGENTTEAMS_FS_ENDPOINT"] == "https://oss.example.test" - assert env["QWENPAW_WORKING_DIR"] == str(tmp_path / ".qwenpaw") - assert "AGENTTEAMS_FS_ACCESS_KEY" not in env - assert "AGENTTEAMS_FS_SECRET_KEY" not in env - assert "MC_HOST_agentteams" not in env - - -def test_teamharness_installs_workspace_teams_md_without_overwriting_agentspec(tmp_path: Path, monkeypatch) -> None: - module = _load_plugin() - runtime_config = tmp_path / "runtime.yaml" - shared_dir = tmp_path / "shared" - workspace_dir = tmp_path / "workspace" - agent_home = tmp_path / "agent-home" - _runtime_yaml(runtime_config) - workspace_dir.mkdir() - agent_home.mkdir() - (workspace_dir / "AGENTS.md").write_text("agentspec agents\n", encoding="utf-8") - (workspace_dir / "SOUL.md").write_text("agentspec soul\n", encoding="utf-8") - (agent_home / "SOUL.md").write_text("outer soul should not be copied\n", encoding="utf-8") - - monkeypatch.setenv("TEAMHARNESS_RUNTIME_CONFIG", str(runtime_config)) - monkeypatch.setenv("TEAMHARNESS_SHARED_DIR", str(shared_dir)) - monkeypatch.setenv("QWENPAW_WORKSPACE_DIR", str(workspace_dir)) - monkeypatch.setenv("AGENTTEAMS_AGENT_HOME", str(agent_home)) - monkeypatch.setenv("AGENTTEAMS_WORKER_MATRIX_TOKEN", "matrix-secret-value") - - result = module.apply_teamharness() - - teams_md = workspace_dir / "TEAMS.md" - text = teams_md.read_text(encoding="utf-8") - assert result["ok"] is True - assert "demo-team" in text - assert "worker-a" in text - assert "leader" in text - assert "qa-runtime" in text - assert "!team:matrix.local" in text - assert "dev-worker" in text - assert module.TEAMS_INTERNAL_CONTROL_MARKER in text - assert "Worker Role" in text - assert "Matrix Reply Discipline" in text - assert "Long Matrix Messages" in text - assert "roomflow describe_room" in text - assert "TASK:" in text - assert "Matrix Mentions" in text - assert "artifact publish_file" in text - assert "matrix-secret-value" not in text - assert not (shared_dir / "TEAMS.md").exists() - assert (workspace_dir / "AGENTS.md").read_text(encoding="utf-8") == "agentspec agents\n" - assert (workspace_dir / "SOUL.md").read_text(encoding="utf-8") == "agentspec soul\n" - - -def test_team_leader_role_uses_leader_prompt(tmp_path: Path, monkeypatch) -> None: - module = _load_plugin() - runtime_config = tmp_path / "runtime.yaml" - workspace_dir = tmp_path / "workspace" - _leader_runtime_yaml(runtime_config) - workspace_dir.mkdir() - - monkeypatch.setenv("TEAMHARNESS_RUNTIME_CONFIG", str(runtime_config)) - monkeypatch.setenv("QWENPAW_WORKSPACE_DIR", str(workspace_dir)) - - result = module.apply_teamharness() - - teams_md = workspace_dir / "TEAMS.md" - text = teams_md.read_text(encoding="utf-8") - assert result["ok"] is True - assert "Leader Role" in text - assert "member.role: team_leader" in text - - -def test_teamharness_enables_role_skills_in_workspace(tmp_path: Path, monkeypatch) -> None: - module = _load_plugin() - runtime_config = tmp_path / "runtime.yaml" - workspace_dir = tmp_path / "workspace" - skill_pool_dir = tmp_path / "skill-pool" - _runtime_yaml(runtime_config) - workspace_dir.mkdir() - - monkeypatch.setenv("TEAMHARNESS_RUNTIME_CONFIG", str(runtime_config)) - monkeypatch.setenv("QWENPAW_WORKSPACE_DIR", str(workspace_dir)) - - registry_module = types.ModuleType("qwenpaw.agents.skill_system.registry") - registry_module.ensure_skill_pool_initialized = lambda: None - registry_module.reconcile_pool_manifest = lambda: None - - store_module = types.ModuleType("qwenpaw.agents.skill_system.store") - store_module.get_skill_pool_dir = lambda: skill_pool_dir - - class SkillPoolService: - def download_to_workspace(self, skill_name, target_workspace, *, overwrite=False): - source = skill_pool_dir / skill_name - target = Path(target_workspace) / "skills" / skill_name - target.parent.mkdir(parents=True, exist_ok=True) - if target.exists(): - shutil.rmtree(target) - shutil.copytree(source, target) - manifest_path = Path(target_workspace) / "skill.json" - manifest_path.write_text( - json.dumps( - { - "schema_version": "workspace-skill-manifest.v1", - "skills": {name: {"enabled": True} for name in sorted(p.name for p in target.parent.iterdir())}, - } - ), - encoding="utf-8", - ) - return {"success": True, "name": skill_name} - - pool_service_module = types.ModuleType("qwenpaw.agents.skill_system.pool_service") - pool_service_module.SkillPoolService = SkillPoolService - - monkeypatch.setitem(sys.modules, "qwenpaw", types.ModuleType("qwenpaw")) - monkeypatch.setitem(sys.modules, "qwenpaw.agents", types.ModuleType("qwenpaw.agents")) - monkeypatch.setitem(sys.modules, "qwenpaw.agents.skill_system", types.ModuleType("qwenpaw.agents.skill_system")) - monkeypatch.setitem(sys.modules, "qwenpaw.agents.skill_system.registry", registry_module) - monkeypatch.setitem(sys.modules, "qwenpaw.agents.skill_system.store", store_module) - monkeypatch.setitem(sys.modules, "qwenpaw.agents.skill_system.pool_service", pool_service_module) - - result = module.apply_teamharness() - - assert result["ok"] is True - assert (workspace_dir / "skills" / "teamharness-task-execution" / "SKILL.md").is_file() - assert (workspace_dir / "skills" / "teamharness-communication" / "SKILL.md").is_file() - assert not (workspace_dir / "skills" / "teamharness-task-delegation").exists() - task_execution_text = ( - workspace_dir / "skills" / "teamharness-task-execution" / "SKILL.md" - ).read_text(encoding="utf-8") - assert "automatically publishes" in task_execution_text - assert "publishedArtifacts" in task_execution_text - file_sharing_text = ( - workspace_dir / "skills" / "teamharness-file-sharing" / "SKILL.md" - ).read_text(encoding="utf-8") - assert "artifact publish_file" in file_sharing_text - assert "workspace-relative file path" in file_sharing_text - manifest = json.loads((workspace_dir / "skill.json").read_text(encoding="utf-8")) - assert manifest["skills"]["teamharness-task-execution"]["enabled"] is True - assert "teamharness-task-delegation" not in manifest["skills"] - - -def test_sanitizer_redacts_keywords_and_env_values(tmp_path: Path, monkeypatch) -> None: - module = _load_plugin() - runtime_config = tmp_path / "runtime.yaml" - _runtime_yaml(runtime_config) - - monkeypatch.setenv("TEAMHARNESS_RUNTIME_CONFIG", str(runtime_config)) - monkeypatch.setenv("AGENTTEAMS_WORKER_MATRIX_TOKEN", "matrix-secret-value") - monkeypatch.setenv("EXTRA_SECRET", "extra-secret-value") - - text = module.sanitize_text( - "tool saw internal-token plus matrix-secret-value and extra-secret-value" - ) - - assert text == "tool saw [REDACTED] plus [REDACTED] and [REDACTED]" - - -def test_sanitizer_restores_builtin_cloud_secret_patterns(monkeypatch) -> None: - module = _load_plugin() - monkeypatch.delenv("TEAMHARNESS_RUNTIME_CONFIG", raising=False) - monkeypatch.delenv("QWENPAW_WORKSPACE_DIR", raising=False) - - raw_secret = "abcdefghijklmnopqrstuvwxyz123456" - text = module.sanitize_text( - f"access_key_secret={raw_secret}\n" - "aliyun id LTAIabcdefghijklmnop\n" - f"token={raw_secret}" - ) - - assert raw_secret not in text - assert "access_key_secret=********" in text - assert "LTAI****" in text - assert "token=********" in text - - -def test_credential_guard_reads_workspace_credagent_and_updates_qwenpaw_security( - tmp_path: Path, - monkeypatch, -) -> None: - module = _load_plugin() - qwenpaw_dir = tmp_path / ".qwenpaw" - workspace_dir = qwenpaw_dir / "workspaces" / "default" - workspace_config = workspace_dir / "config" - workspace_config.mkdir(parents=True) - qwenpaw_dir.mkdir(exist_ok=True) - (workspace_config / "credagent.json").write_text( - json.dumps( - { - "credentials": [ - { - "path": "~/agentteams/credentials/matrix/password", - "programPermit": "qwenpaw", - }, - { - "path": "/etc/agentteams/secrets/", - "programPermit": ["mc", "qwenpaw"], - "writable": True, - }, - ], - "output_sanitize": [ - {"type": "keyword", "keywords": ["privateToken"]}, - ], - } - ), - encoding="utf-8", - ) - (qwenpaw_dir / "config.json").write_text( - json.dumps( - { - "security": { - "tool_guard": {"enabled": False, "auto_denied_rules": ["EXISTING_RULE"]}, - "file_guard": { - "enabled": False, - "sensitive_files": ["/old/removed-secret", "/manual/keep"], - }, - }, - "plugins": { - "teamharness": { - "credentialGuard": { - "paths": ["/old/removed-secret"], - }, - }, - }, - } - ), - encoding="utf-8", - ) +class FakeApi: + def __init__(self): + self.calls = [] - monkeypatch.setenv("QWENPAW_WORKING_DIR", str(qwenpaw_dir)) - monkeypatch.setenv("QWENPAW_WORKSPACE_DIR", str(workspace_dir)) + def __getattr__(self, name): + def call(*args, **kwargs): + self.calls.append((name, args, kwargs)) - result = module.apply_credential_guard() + return call - assert result["ok"] is True - assert result["credentials"] == 2 - assert result["outputSanitizeRules"] == 1 - config = json.loads((qwenpaw_dir / "config.json").read_text(encoding="utf-8")) - security = config["security"] - sensitive_files = security["file_guard"]["sensitive_files"] - assert security["file_guard"]["enabled"] is True - assert security["tool_guard"]["enabled"] is True - assert "SENSITIVE_FILE_BLOCK" in security["tool_guard"]["auto_denied_rules"] - assert "EXISTING_RULE" in security["tool_guard"]["auto_denied_rules"] - assert "/manual/keep" in sensitive_files - assert "/old/removed-secret" not in sensitive_files - assert "/etc/agentteams/secrets/" in sensitive_files - assert any(path.endswith("/agentteams/credentials/matrix/password") for path in sensitive_files) - raw_secret = "abcdefghijklmnopqrstuvwxyz123456" - assert raw_secret not in module.sanitize_text(f"privateToken={raw_secret}") +def test_register_uses_qwenpaw_2_public_extension_points(monkeypatch): + module = load_plugin() + class Router: + def get(self, _path): + return lambda function: function + def post(self, _path): + return lambda function: function -def test_private_tool_result_wrapper_redacts_text_blocks(tmp_path: Path, monkeypatch) -> None: - module = _load_plugin() - runtime_config = tmp_path / "runtime.yaml" - _runtime_yaml(runtime_config) - monkeypatch.setenv("TEAMHARNESS_RUNTIME_CONFIG", str(runtime_config)) - - result = { - "content": [ - {"type": "text", "text": "internal-token"}, - {"type": "image", "url": "keep"}, - ], - } - - assert module.sanitize_tool_result(result)["redacted"] is True - assert result["content"][0]["text"] == "[REDACTED]" - - -def test_output_sanitizer_wrapper_preserves_async_tool_stream( - tmp_path: Path, - monkeypatch, -) -> None: - module = _load_plugin() - runtime_config = tmp_path / "runtime.yaml" - _runtime_yaml(runtime_config) - monkeypatch.setenv("TEAMHARNESS_RUNTIME_CONFIG", str(runtime_config)) - - class QwenPawAgent: - async def _acting(self, _tool_call): - yield {"content": [{"type": "text", "text": "internal-token"}]} - yield {"content": [{"type": "text", "text": "safe"}]} - - qwenpaw_module = types.ModuleType("qwenpaw") - agents_module = types.ModuleType("qwenpaw.agents") - react_agent_module = types.ModuleType("qwenpaw.agents.react_agent") - react_agent_module.QwenPawAgent = QwenPawAgent - monkeypatch.setitem(sys.modules, "qwenpaw", qwenpaw_module) - monkeypatch.setitem(sys.modules, "qwenpaw.agents", agents_module) - monkeypatch.setitem(sys.modules, "qwenpaw.agents.react_agent", react_agent_module) - - result = module.install_output_sanitizer_wrapper() - - async def collect(): - return [chunk async for chunk in QwenPawAgent()._acting({})] - - chunks = asyncio.run(collect()) - assert result == {"ok": True, "installed": True, "action": "created"} - assert chunks[0]["content"][0]["text"] == "[REDACTED]" - assert chunks[1]["content"][0]["text"] == "safe" - - -def test_internal_mcp_policy_allows_only_teamharness(tmp_path: Path, monkeypatch) -> None: - module = _load_plugin() - - def legacy_mcp_client_to_driver(_client_key, _config): - policy = types.SimpleNamespace(default_effect="ask", rules=["ask"]) - return types.SimpleNamespace(policy=policy), None - - qwenpaw_module = types.ModuleType("qwenpaw") - drivers_module = types.ModuleType("qwenpaw.drivers") - adapters_module = types.ModuleType("qwenpaw.drivers.adapters") - legacy_module = types.ModuleType("qwenpaw.drivers.adapters.mcp_legacy_config") - constant_module = types.ModuleType("qwenpaw.constant") - storage_module = types.ModuleType("qwenpaw.drivers.storage") - constant_module.WORKING_DIR = tmp_path / ".qwenpaw" - legacy_module.legacy_mcp_client_to_driver = legacy_mcp_client_to_driver - adapters_module.mcp_legacy_config = legacy_module - persisted = types.SimpleNamespace( - protocol="mcp", - policy=types.SimpleNamespace(default_effect="deny", rules=["ask"]), - ) - card_path = tmp_path / ".qwenpaw" / "workspaces" / "default" / "drivers" / "mcp" / "teamharness.yaml" - card_path.parent.mkdir(parents=True) - card_path.write_text("existing", encoding="utf-8") - storage_module.card_paths_for_name = lambda _cards_dir, _name: [card_path] - storage_module.load_card = lambda _path: persisted - storage_module.dump_card = lambda _card, path: path.write_text("updated", encoding="utf-8") - monkeypatch.setitem(sys.modules, "qwenpaw", qwenpaw_module) - monkeypatch.setitem(sys.modules, "qwenpaw.drivers", drivers_module) - monkeypatch.setitem(sys.modules, "qwenpaw.drivers.adapters", adapters_module) - monkeypatch.setitem(sys.modules, "qwenpaw.constant", constant_module) - monkeypatch.setitem(sys.modules, "qwenpaw.drivers.storage", storage_module) monkeypatch.setitem( sys.modules, - "qwenpaw.drivers.adapters.mcp_legacy_config", - legacy_module, - ) - - result = module.install_internal_mcp_allow_policy() - internal, _ = legacy_module.legacy_mcp_client_to_driver("teamharness", {}) - external, _ = legacy_module.legacy_mcp_client_to_driver("external", {}) - - assert result == {"ok": True, "installed": True, "action": "created"} - assert internal.policy.default_effect == "allow" - assert internal.policy.rules == [] - assert external.policy.default_effect == "ask" - assert external.policy.rules == ["ask"] - assert persisted.policy.default_effect == "allow" - assert persisted.policy.rules == [] - - -def test_teamharness_http_sync_reinstalls_runtime_hooks(monkeypatch) -> None: - module = _load_plugin() - calls = [] - - monkeypatch.setattr(module, "apply_teamharness", lambda: {"ok": True, "applied": True}) - monkeypatch.setattr( - module, - "install_internal_mcp_allow_policy", - lambda: calls.append(("policy", None)) or {"ok": True, "installed": True}, + "fastapi", + types.SimpleNamespace(APIRouter=Router), ) monkeypatch.setattr( module, - "install_output_sanitizer_wrapper", - lambda api=None: calls.append(("sanitizer", api)) or {"ok": True, "installed": True}, + "_register_trace_hooks", + lambda api: api.calls.append(("trace", (), {})), ) - - class APIRouter: - def __init__(self): - self.routes = {} - - def get(self, path): - def decorator(func): - self.routes[("GET", path)] = func - return func - - return decorator - - def post(self, path): - def decorator(func): - self.routes[("POST", path)] = func - return func - - return decorator - - fastapi_module = types.ModuleType("fastapi") - fastapi_module.APIRouter = APIRouter - monkeypatch.setitem(sys.modules, "fastapi", fastapi_module) - - class Api: - def __init__(self): - self.routers = {} - - def register_startup_hook(self, *_args, **_kwargs): - pass - - def register_shutdown_hook(self, *_args, **_kwargs): - pass - - def register_http_router(self, router, prefix, tags): - self.routers[prefix] = router - - api = Api() - plugin = module.TeamHarnessPlugin() - plugin.register(api) - - result = api.routers["/teamharness"].routes[("POST", "/sync")]() - - assert result == { - "ok": True, - "applied": True, - "driverPolicy": {"ok": True, "installed": True}, - } - assert calls == [("policy", None), ("sanitizer", api)] - assert plugin.sanitizer_result == {"ok": True, "installed": True} - - -def test_mcp_client_receives_matrix_env_for_message_tool(tmp_path: Path, monkeypatch) -> None: - module = _load_plugin() - workspace_dir = tmp_path / "workspace" - shared_dir = tmp_path / "shared" - workspace_dir.mkdir() - - class MCPClientConfig: - def __init__(self, **kwargs): - self.__dict__.update(kwargs) - - class MCPConfig: - def __init__(self): - self.clients = {} - - class AgentConfig: - def __init__(self): - self.mcp = None - - agent_config = AgentConfig() - saved = {} - - def load_agent_config(agent_id): - saved["loaded_agent"] = agent_id - return agent_config - - def save_agent_config(agent_id, config): - saved["saved_agent"] = agent_id - saved["config"] = config - - config_module = types.ModuleType("qwenpaw.config.config") - config_module.MCPClientConfig = MCPClientConfig - config_module.MCPConfig = MCPConfig - config_module.load_agent_config = load_agent_config - config_module.save_agent_config = save_agent_config - - monkeypatch.setitem(sys.modules, "qwenpaw", types.ModuleType("qwenpaw")) - monkeypatch.setitem(sys.modules, "qwenpaw.config", types.ModuleType("qwenpaw.config")) - monkeypatch.setitem(sys.modules, "qwenpaw.config.config", config_module) - monkeypatch.setenv("TEAMHARNESS_SHARED_DIR", str(shared_dir)) - monkeypatch.setenv("TEAMHARNESS_RUNTIME_CONFIG", "/root/agentteams-fs/shared/runtime/members/worker-a/runtime.yaml") - monkeypatch.setenv("AGENTTEAMS_MATRIX_URL", "http://matrix.local") - monkeypatch.setenv("AGENTTEAMS_WORKER_MATRIX_TOKEN", "matrix-token") - monkeypatch.setenv("AGENTTEAMS_WORKER_ROLE", "worker") - monkeypatch.setenv("AGENTTEAMS_WORKER_NAME", "worker-a") - monkeypatch.setenv("AGENTTEAMS_STORAGE_PREFIX", "agentteams/agentteams-storage") - monkeypatch.setenv("AGENTTEAMS_FS_BUCKET", "agentteams-storage") - - result = module._ensure_mcp_client("default", workspace_dir) - - assert result == {"agent": "default", "action": "configured"} - assert saved["loaded_agent"] == "default" - assert saved["saved_agent"] == "default" - client = agent_config.mcp.clients["teamharness"] - assert client.command == sys.executable - assert client.env["TEAMHARNESS_SHARED_DIR"] == str(shared_dir) - assert client.env["LOONGSUITE_PYTHON_SITE_BOOTSTRAP_LOG_SUCCESS"] == "false" - assert client.env["TEAMHARNESS_RUNTIME_CONFIG"] == "/root/agentteams-fs/shared/runtime/members/worker-a/runtime.yaml" - assert client.env["AGENTTEAMS_MATRIX_URL"] == "http://matrix.local" - assert client.env["AGENTTEAMS_WORKER_MATRIX_TOKEN"] == "matrix-token" - assert client.env["AGENTTEAMS_WORKER_ROLE"] == "worker" - assert client.env["AGENTTEAMS_WORKER_NAME"] == "worker-a" - assert client.env["AGENTTEAMS_STORAGE_PREFIX"] == "agentteams/agentteams-storage" - assert client.env["AGENTTEAMS_FS_BUCKET"] == "agentteams-storage" + api = FakeApi() + module.plugin.register(api) + names = [call[0] for call in api.calls] + assert names == [ + "register_prompt_section", + "register_skill_provider", + "register_middleware", + "trace", + "register_http_router", + ] + + +def test_sanitizer_redacts_tool_output(monkeypatch): + module = load_plugin() + monkeypatch.setenv("AGENTTEAMS_OUTPUT_SANITIZE_KEYWORDS", "secret-value") + value = {"content": [{"text": "token=secret-value"}]} + module._sanitize(value) + assert value["content"][0]["text"] == "token=[REDACTED]" + + +def test_team_prompt_reads_packaged_contract(): + module = load_plugin() + assert "TeamHarness" in module._team_prompt(None) + + +def test_plugin_does_not_patch_qwenpaw_private_runtime(): + source = PLUGIN_PATH.read_text(encoding="utf-8") + assert "QwenPawAgent._acting" not in source + assert "legacy_mcp_client_to_driver" not in source + assert "save_agent_config" not in source diff --git a/plugins/tests/teamharness/adapters/qwenpaw/test_package.py b/plugins/tests/teamharness/adapters/qwenpaw/test_package.py index 001e9fab3..aab5790f1 100644 --- a/plugins/tests/teamharness/adapters/qwenpaw/test_package.py +++ b/plugins/tests/teamharness/adapters/qwenpaw/test_package.py @@ -58,6 +58,7 @@ def test_build_qwenpaw_native_plugin_package(tmp_path: Path) -> None: assert f"{root}/teamharness/prompts/agent/worker.md" in names assert f"{root}/teamharness/skills/team/communication/SKILL.md" in names assert f"{root}/teamharness/skills/team/roomflow/SKILL.md" in names + assert f"{root}/teamharness/qwenpaw-skills/communication/SKILL.md" in names assert f"{root}/teamharness/mcp/server.py" in names assert f"{root}/teamharness/mcp/message_tool.py" in names assert f"{root}/teamharness/mcp/roomflow_tool.py" in names @@ -79,33 +80,9 @@ def test_build_qwenpaw_native_plugin_package(tmp_path: Path) -> None: assert manifest["version"] == version assert manifest["entry"]["backend"] == "plugin.py" assert manifest["qwenpaw_version"] == { - "min": "2.0.0", + "min": "2.0.1", "max": "2.1.0", } assert "periodic-sync" not in manifest["meta"]["features"] - probe = subprocess.run( - [ - sys.executable, - "-c", - ( - "import importlib.util, json; " - f"path = {str(extract_dir / root / 'plugin.py')!r}; " - "spec = importlib.util.spec_from_file_location('teamharness_pkg', path); " - "mod = importlib.util.module_from_spec(spec); " - "spec.loader.exec_module(mod); " - "print(json.dumps(mod.install_task_trace_processor()))" - ), - ], - env={ - **os.environ, - "PYTHONDONTWRITEBYTECODE": "1", - "QWENPAW_WORKING_DIR": str(tmp_path / ".qwenpaw"), - }, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=True, - ) - trace_result = json.loads(probe.stdout) - assert trace_result.get("reason") != "task_trace.py not found" + assert manifest["min_version"] == "2.0.1" diff --git a/plugins/tests/teamharness/test_trace.py b/plugins/tests/teamharness/test_trace.py index 13a18b0a4..6bbd92a08 100644 --- a/plugins/tests/teamharness/test_trace.py +++ b/plugins/tests/teamharness/test_trace.py @@ -497,7 +497,7 @@ def test_registers_on_real_tracer_provider(self, workspace: Path) -> None: # =================================================================== -class TestAdapterInstallTaskTraceProcessor: +class LegacyAdapterMonkeyPatchTraceTests: def test_extracts_matrix_room_from_request_context(self) -> None: mod = _load_adapter_module() assert mod._room_id_from_request_context({ diff --git a/plugins/tests/workerflow/adapters/qwenpaw/test_adapter.py b/plugins/tests/workerflow/adapters/qwenpaw/test_adapter.py deleted file mode 100644 index ccfffeda0..000000000 --- a/plugins/tests/workerflow/adapters/qwenpaw/test_adapter.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Tests for WorkerFlow QwenPaw adapter wiring.""" - -from __future__ import annotations - -import importlib.util -import os -from pathlib import Path -import sys -import tempfile -import types -import unittest -from unittest import mock - - -REPO_ROOT = Path(__file__).resolve().parents[5] -PLUGIN_PATH = REPO_ROOT / "plugins" / "workerflow" / "adapters" / "qwenpaw" / "plugin.py" - - -def load_plugin(): - spec = importlib.util.spec_from_file_location("workerflow_qwenpaw_plugin_test", PLUGIN_PATH) - if spec is None or spec.loader is None: - raise RuntimeError(f"cannot load plugin from {PLUGIN_PATH}") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -class WorkerFlowQwenPawAdapterTest(unittest.TestCase): - def setUp(self) -> None: - self.temp_dir_context = tempfile.TemporaryDirectory() - self.temp_dir = Path(self.temp_dir_context.name) - self.env_keys = [ - "TEAMHARNESS_RUNTIME_CONFIG", - "AGENTTEAMS_MEMBER_RUNTIME_CONFIG", - "AGENTTEAMS_MATRIX_URL", - "AGENTTEAMS_MATRIX_SERVER", - "AGENTTEAMS_MATRIX_HOMESERVER", - "AGENTTEAMS_WORKER_MATRIX_TOKEN", - "AGENTTEAMS_MATRIX_TOKEN", - "AGENTTEAMS_MATRIX_USER_ID", - "AGENTTEAMS_MATRIX_DOMAIN", - "AGENTTEAMS_WORKER_ROLE", - "AGENTTEAMS_AGENT_ROLE", - "AGENTTEAMS_WORKER_NAME", - "QWENPAW_WORKING_DIR", - ] - self.old_env = {key: os.environ.get(key) for key in self.env_keys} - for key in self.env_keys: - os.environ.pop(key, None) - - def tearDown(self) -> None: - for key, value in self.old_env.items(): - if value is None: - os.environ.pop(key, None) - else: - os.environ[key] = value - self.temp_dir_context.cleanup() - - def test_mcp_client_env_includes_runtime_and_matrix_inputs(self) -> None: - module = load_plugin() - os.environ["TEAMHARNESS_RUNTIME_CONFIG"] = "/shared/runtime/worker/runtime.yaml" - os.environ["AGENTTEAMS_MATRIX_URL"] = "http://matrix.local" - os.environ["AGENTTEAMS_WORKER_MATRIX_TOKEN"] = "matrix-token" - os.environ["AGENTTEAMS_MATRIX_USER_ID"] = "@worker:matrix.local" - os.environ["AGENTTEAMS_WORKER_NAME"] = "worker-a" - os.environ["QWENPAW_WORKING_DIR"] = "/root/.qwenpaw" - - env = module._mcp_client_env() - - self.assertEqual(env["TEAMHARNESS_RUNTIME_CONFIG"], "/shared/runtime/worker/runtime.yaml") - self.assertEqual(env["AGENTTEAMS_MATRIX_URL"], "http://matrix.local") - self.assertEqual(env["AGENTTEAMS_WORKER_MATRIX_TOKEN"], "matrix-token") - self.assertEqual(env["AGENTTEAMS_MATRIX_USER_ID"], "@worker:matrix.local") - self.assertEqual(env["AGENTTEAMS_WORKER_NAME"], "worker-a") - self.assertEqual(env["QWENPAW_WORKING_DIR"], "/root/.qwenpaw") - - def test_internal_mcp_policy_allows_only_workerflow(self) -> None: - module = load_plugin() - working_dir = self.temp_dir / ".qwenpaw" - os.environ["QWENPAW_WORKING_DIR"] = str(working_dir) - - def legacy_mcp_client_to_driver(_client_key, _config): - policy = types.SimpleNamespace(default_effect="ask", rules=["ask"]) - return types.SimpleNamespace(policy=policy), None - - qwenpaw_module = types.ModuleType("qwenpaw") - drivers_module = types.ModuleType("qwenpaw.drivers") - adapters_module = types.ModuleType("qwenpaw.drivers.adapters") - legacy_module = types.ModuleType("qwenpaw.drivers.adapters.mcp_legacy_config") - constant_module = types.ModuleType("qwenpaw.constant") - storage_module = types.ModuleType("qwenpaw.drivers.storage") - constant_module.WORKING_DIR = working_dir - legacy_module.legacy_mcp_client_to_driver = legacy_mcp_client_to_driver - adapters_module.mcp_legacy_config = legacy_module - persisted = types.SimpleNamespace( - protocol="mcp", - policy=types.SimpleNamespace(default_effect="deny", rules=["ask"]), - ) - card_path = working_dir / "workspaces" / "default" / "drivers" / "mcp" / "workerflow.yaml" - card_path.parent.mkdir(parents=True) - card_path.write_text("existing", encoding="utf-8") - storage_module.card_paths_for_name = lambda _cards_dir, _name: [card_path] - storage_module.load_card = lambda _path: persisted - storage_module.dump_card = lambda _card, path: path.write_text("updated", encoding="utf-8") - - with mock.patch.dict( - sys.modules, - { - "qwenpaw": qwenpaw_module, - "qwenpaw.drivers": drivers_module, - "qwenpaw.drivers.adapters": adapters_module, - "qwenpaw.drivers.adapters.mcp_legacy_config": legacy_module, - "qwenpaw.constant": constant_module, - "qwenpaw.drivers.storage": storage_module, - }, - ): - result = module.install_internal_mcp_allow_policy() - internal, _ = legacy_module.legacy_mcp_client_to_driver("workerflow", {}) - external, _ = legacy_module.legacy_mcp_client_to_driver("external", {}) - - self.assertEqual(result, {"ok": True, "installed": True, "action": "created"}) - self.assertEqual(internal.policy.default_effect, "allow") - self.assertEqual(internal.policy.rules, []) - self.assertEqual(external.policy.default_effect, "ask") - self.assertEqual(external.policy.rules, ["ask"]) - self.assertEqual(persisted.policy.default_effect, "allow") - self.assertEqual(persisted.policy.rules, []) - - -if __name__ == "__main__": - unittest.main(verbosity=2) diff --git a/plugins/tests/workerflow/adapters/qwenpaw/test_workerflow_adapter.py b/plugins/tests/workerflow/adapters/qwenpaw/test_workerflow_adapter.py new file mode 100644 index 000000000..52cd4f4fe --- /dev/null +++ b/plugins/tests/workerflow/adapters/qwenpaw/test_workerflow_adapter.py @@ -0,0 +1,36 @@ +"""QwenPaw 2 public plugin API contract tests for WorkerFlow.""" + +import importlib.util +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[5] +PLUGIN_PATH = ROOT / "plugins" / "workerflow" / "adapters" / "qwenpaw" / "plugin.py" + + +def test_register_uses_skill_provider_api(): + spec = importlib.util.spec_from_file_location( + "workerflow_qwenpaw_plugin_test", + PLUGIN_PATH, + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + calls = [] + + class Api: + def register_skill_provider(self, *args, **kwargs): + calls.append(("skill", args, kwargs)) + + def register_http_router(self, *args, **kwargs): + calls.append(("http", args, kwargs)) + + module.plugin.register(Api()) + assert calls[0][1][0].name == "agent" + assert calls[0][2] == {"enabled_by_default": True, "channels": ["all"]} + + +def test_plugin_does_not_patch_qwenpaw_private_runtime(): + source = PLUGIN_PATH.read_text(encoding="utf-8") + assert "legacy_mcp_client_to_driver" not in source + assert "drivers.storage" not in source + assert "save_agent_config" not in source diff --git a/plugins/tests/workerflow/adapters/qwenpaw/test_workerflow_package.py b/plugins/tests/workerflow/adapters/qwenpaw/test_workerflow_package.py index ad3d29464..67abfc490 100644 --- a/plugins/tests/workerflow/adapters/qwenpaw/test_workerflow_package.py +++ b/plugins/tests/workerflow/adapters/qwenpaw/test_workerflow_package.py @@ -63,7 +63,7 @@ def test_build_qwenpaw_native_workerflow_plugin_package(tmp_path: Path) -> None: assert manifest["version"] == version assert manifest["entry"]["backend"] == "plugin.py" assert manifest["qwenpaw_version"] == { - "min": "2.0.0", + "min": "2.0.1", "max": "2.1.0", } assert "workerflow-mcp" in manifest["meta"]["features"] diff --git a/plugins/workerflow/adapters/qwenpaw/plugin.py b/plugins/workerflow/adapters/qwenpaw/plugin.py index 860550308..16537eb20 100644 --- a/plugins/workerflow/adapters/qwenpaw/plugin.py +++ b/plugins/workerflow/adapters/qwenpaw/plugin.py @@ -1,13 +1,8 @@ -#!/usr/bin/env python3 -"""QwenPaw adapter for WorkerFlow.""" +"""WorkerFlow integration implemented with QwenPaw 2 public plugin APIs.""" from __future__ import annotations -import json -import os from pathlib import Path -import shutil -import sys from typing import Any @@ -16,262 +11,40 @@ if not (ASSET_DIR / "plugin.yaml").exists(): ASSET_DIR = PLUGIN_DIR.parent.parent -MCP_CLIENT_ID = "workerflow" - - -def _read_yaml(path: Path) -> dict[str, Any]: - try: - import yaml - - data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - except (FileNotFoundError, ImportError): - return {} - return data if isinstance(data, dict) else {} - - -def _section(data: dict[str, Any], key: str) -> dict[str, Any]: - value = data.get(key) or {} - return value if isinstance(value, dict) else {} - - -def _iter_qwenpaw_agents() -> list[tuple[str, Path]]: - try: - from qwenpaw.config.utils import load_config - except ImportError: - workspace = os.getenv("QWENPAW_WORKSPACE_DIR", "").strip() - return [("default", Path(workspace))] if workspace else [] - - root = load_config() - profiles = getattr(getattr(root, "agents", None), "profiles", {}) or {} - agents: list[tuple[str, Path]] = [] - for agent_id, ref in profiles.items(): - if getattr(ref, "enabled", True) is False: - continue - workspace_dir = Path(getattr(ref, "workspace_dir", "")).expanduser() - if str(workspace_dir): - agents.append((str(agent_id), workspace_dir)) - return agents - - -def _copytree_replace(source: Path, target: Path) -> None: - if target.exists(): - shutil.rmtree(target) - shutil.copytree(source, target, ignore=shutil.ignore_patterns("__pycache__", ".DS_Store", "*.pyc")) - - -def _skill_entries() -> list[dict[str, Any]]: - manifest = _read_yaml(ASSET_DIR / "plugin.yaml") - entries: list[dict[str, Any]] = [] - for group_entries in _section(manifest, "skills").values(): - if isinstance(group_entries, list): - for entry in group_entries: - if isinstance(entry, dict): - entries.append(entry) - return entries - - -def _install_skills() -> dict[str, Any]: - installed: list[str] = [] - try: - from qwenpaw.agents.skill_system.registry import ensure_skill_pool_initialized, reconcile_pool_manifest - from qwenpaw.agents.skill_system.store import get_skill_pool_dir - except ImportError: - return {"installed": installed, "skipped": "qwenpaw skill API unavailable"} - - ensure_skill_pool_initialized() - pool_dir = get_skill_pool_dir() - pool_dir.mkdir(parents=True, exist_ok=True) - for entry in _skill_entries(): - skill_id = str(entry.get("id") or "").strip() - source = ASSET_DIR / str(entry.get("path") or "") - if not skill_id or not source.exists(): - continue - target = pool_dir / f"workerflow-{skill_id}" - _copytree_replace(source, target) - installed.append(target.name) - reconcile_pool_manifest() - return {"installed": installed} - - -def _enable_workspace_skills(workspace_dir: Path) -> dict[str, Any]: - installed: list[str] = [] - failed: list[dict[str, str]] = [] - try: - from qwenpaw.agents.skill_system.pool_service import SkillPoolService - except ImportError: - return {"installed": installed, "skipped": "qwenpaw skill pool API unavailable"} - - service = SkillPoolService() - for entry in _skill_entries(): - skill_id = str(entry.get("id") or "").strip() - if not skill_id: - continue - skill_name = f"workerflow-{skill_id}" - result = service.download_to_workspace(skill_name, workspace_dir, overwrite=True) - if result.get("success"): - installed.append(skill_name) - else: - failed.append({"name": skill_name, "reason": str(result.get("reason") or "unknown")}) - return {"installed": installed, "failed": failed} - - -def _mcp_client_env() -> dict[str, str]: - env: dict[str, str] = {} - for name in ( - "TEAMHARNESS_RUNTIME_CONFIG", - "AGENTTEAMS_MEMBER_RUNTIME_CONFIG", - "AGENTTEAMS_MATRIX_URL", - "AGENTTEAMS_MATRIX_SERVER", - "AGENTTEAMS_MATRIX_HOMESERVER", - "AGENTTEAMS_WORKER_MATRIX_TOKEN", - "AGENTTEAMS_MATRIX_TOKEN", - "AGENTTEAMS_MATRIX_USER_ID", - "AGENTTEAMS_MATRIX_DOMAIN", - "AGENTTEAMS_WORKER_ROLE", - "AGENTTEAMS_AGENT_ROLE", - "AGENTTEAMS_WORKER_NAME", - "QWENPAW_API_BASE_URL", - "QWENPAW_BASE_URL", - "QWENPAW_WORKING_DIR", - "QWENPAW_WORKSPACE_DIR", - "COPAW_WORKING_DIR", - ): - value = os.getenv(name, "").strip() - if value: - env[name] = value - return env - - -def _ensure_mcp_client(agent_id: str, workspace_dir: Path) -> dict[str, Any]: - try: - from qwenpaw.config.config import MCPClientConfig, MCPConfig, load_agent_config, save_agent_config - except ImportError: - marker = workspace_dir / ".workerflow" / "mcp-client.json" - marker.parent.mkdir(parents=True, exist_ok=True) - marker.write_text(json.dumps({"id": MCP_CLIENT_ID}, indent=2), encoding="utf-8") - return {"agent": agent_id, "action": "marker", "path": str(marker)} - - agent_config = load_agent_config(agent_id) - if agent_config.mcp is None: - agent_config.mcp = MCPConfig() - server_path = ASSET_DIR / "mcp" / "server.py" - client = MCPClientConfig( - name=MCP_CLIENT_ID, - description="Worker-local agent workflow MCP server", - enabled=True, - transport="stdio", - command=sys.executable, - args=[str(server_path)], - cwd=str(ASSET_DIR), - env=_mcp_client_env(), - ) - agent_config.mcp.clients[MCP_CLIENT_ID] = client - save_agent_config(agent_id, agent_config) - return {"agent": agent_id, "action": "configured"} - - -def apply_workerflow() -> dict[str, Any]: - skills = _install_skills() - agents = [] - mcp = [] - for agent_id, workspace_dir in _iter_qwenpaw_agents(): - workspace_dir.mkdir(parents=True, exist_ok=True) - agents.append({"agent": agent_id, "workspace": str(workspace_dir), "skills": _enable_workspace_skills(workspace_dir)}) - mcp.append(_ensure_mcp_client(agent_id, workspace_dir)) - return {"ok": True, "agents": agents, "skills": skills, "mcp": mcp} - - -def _allow_persisted_internal_mcp_policy() -> None: - from qwenpaw.constant import WORKING_DIR - from qwenpaw.drivers.storage import card_paths_for_name, dump_card, load_card - - workspaces_dir = WORKING_DIR / "workspaces" - if not workspaces_dir.is_dir(): - return - for workspace_dir in workspaces_dir.iterdir(): - if not workspace_dir.is_dir(): - continue - cards_dir = workspace_dir / "drivers" - for path in card_paths_for_name(cards_dir, MCP_CLIENT_ID): - card = load_card(path) - if card.protocol != "mcp": - continue - if card.policy.default_effect == "allow" and not card.policy.rules: - continue - card.policy.default_effect = "allow" - card.policy.rules = [] - dump_card(card, path) - - -def install_internal_mcp_allow_policy() -> dict[str, Any]: - try: - from qwenpaw.drivers.adapters import mcp_legacy_config - except ImportError: - return {"ok": True, "installed": False, "reason": "qwenpaw driver API unavailable"} - - allowed_clients = getattr(mcp_legacy_config, "_agentteams_allowed_mcp_clients", set()) - allowed_clients.add(MCP_CLIENT_ID) - mcp_legacy_config._agentteams_allowed_mcp_clients = allowed_clients - _allow_persisted_internal_mcp_policy() - if getattr(mcp_legacy_config, "_agentteams_policy_wrapper_installed", False): - return {"ok": True, "installed": True, "action": "updated"} - - original = mcp_legacy_config.legacy_mcp_client_to_driver - - def _legacy_mcp_client_to_driver(client_key, config): - card, credential = original(client_key, config) - if client_key in mcp_legacy_config._agentteams_allowed_mcp_clients: - card.policy.default_effect = "allow" - card.policy.rules = [] - return card, credential - - mcp_legacy_config.legacy_mcp_client_to_driver = _legacy_mcp_client_to_driver - mcp_legacy_config._agentteams_policy_wrapper_installed = True - return {"ok": True, "installed": True, "action": "created"} - class WorkerFlowPlugin: - def __init__(self) -> None: - self.last_apply_result: dict[str, Any] = {} - self.policy_result: dict[str, Any] = {} - def register(self, api: Any) -> None: - def sync() -> dict[str, Any]: - self.policy_result = install_internal_mcp_allow_policy() - self.last_apply_result = apply_workerflow() - self.last_apply_result["driverPolicy"] = self.policy_result - return self.last_apply_result - - api.register_startup_hook("workerflow_sync", sync, priority=45) + api.register_skill_provider( + ASSET_DIR / "skills" / "agent", + enabled_by_default=True, + channels=["all"], + ) self._register_http(api) def _register_http(self, api: Any) -> None: try: from fastapi import APIRouter - except Exception: + except ImportError: return router = APIRouter() @router.get("/health") def health() -> dict[str, Any]: - return {"ok": True, "plugin": "workerflow", "adapter": "qwenpaw"} + return {"ok": True, "plugin": "workerflow", "adapter": "qwenpaw-2"} - @router.get("/status") - def status() -> dict[str, Any]: + @router.post("/sync") + def sync_endpoint() -> dict[str, Any]: return { "ok": True, - "lastApply": self.last_apply_result, - "driverPolicy": self.policy_result, + "plugin": "workerflow", + "managedBy": "qwenpaw-plugin-api", } - @router.post("/sync") - def sync_endpoint() -> dict[str, Any]: - self.policy_result = install_internal_mcp_allow_policy() - self.last_apply_result = apply_workerflow() - self.last_apply_result["driverPolicy"] = self.policy_result - return self.last_apply_result - - api.register_http_router(router, prefix="/workerflow", tags=["workerflow"]) + api.register_http_router( + router, + prefix="/workerflow", + tags=["workerflow"], + ) plugin = WorkerFlowPlugin() diff --git a/plugins/workerflow/adapters/qwenpaw/scripts/build-qwenpaw-plugin.rb b/plugins/workerflow/adapters/qwenpaw/scripts/build-qwenpaw-plugin.rb index cedbc8857..76db1b803 100755 --- a/plugins/workerflow/adapters/qwenpaw/scripts/build-qwenpaw-plugin.rb +++ b/plugins/workerflow/adapters/qwenpaw/scripts/build-qwenpaw-plugin.rb @@ -105,9 +105,9 @@ def zip_dir(root, package_name, out_path) "backend" => "plugin.py" }, "dependencies" => [], - "min_version" => "1.1.7", + "min_version" => "2.0.1", "qwenpaw_version" => { - "min" => "2.0.0", + "min" => "2.0.1", "max" => "2.1.0" }, "meta" => { diff --git a/qwenpaw/README.md b/qwenpaw/README.md index ad5deefdb..fdc22405e 100644 --- a/qwenpaw/README.md +++ b/qwenpaw/README.md @@ -11,15 +11,18 @@ pieces separate is the main boundary of this integration. ## 1. Worker Daemon The worker daemon is the process started by the QwenPaw worker container. Its -job is to keep local QwenPaw runtime files aligned with the -controller-projected desired state. +job is to reconcile the controller-projected desired state through the public +QwenPaw localhost HTTP API. ### 1.1 Lifecycle - Start from AgentTeams environment variables and worker arguments. - Prepare the QwenPaw working directory and default workspace. -- Prepare image-bundled TeamHarness and WorkerFlow QwenPaw plugin adapters. +- Prepare image-bundled TeamHarness, WorkerFlow, and AgentTeams Matrix plugins. - Start the QwenPaw app process. +- Wait for `/api/version` to report exactly `2.0.1`. +- Apply Agent, Model, MCP, Channel, and ACL desired state through public APIs + and GET-read it back before starting the update loop. - Stop background tasks and the QwenPaw process on shutdown. ### 1.2 Storage Sync @@ -37,23 +40,26 @@ controller-projected desired state. - Apply model, MCP, channel, TeamHarness prompt asset, and AgentSpec package changes from `runtime.yaml`. - Apply `desired.model` to the active QwenPaw agent model. -- Apply `desired.mcpServers` to `config/mcporter.json` using the mcporter JSON - shape, not as QwenPaw MCP clients. -- Apply `desired.channelPolicy` to QwenPaw Matrix access control. +- Apply `desired.mcpServers` as QwenPaw MCP clients through `/api/mcp`. +- Apply `desired.channelPolicy` to the `agentteams_matrix` ACL namespace through + `/api/access-control/agentteams_matrix`. +- Map `desired.channels.matrix` to the custom QwenPaw channel key + `agentteams_matrix`; the built-in `matrix` channel remains unchanged. - Apply `desired.channels.dingtalk` to the active QwenPaw DingTalk channel. When `streaming_enabled` is true, the worker requires a pre-created DingTalk streaming card template id and switches the current DingTalk runtime config to card streaming mode. - Apply AgentSpec package changes inside the same desired-state loop, without restarting the pod. -- Apply AgentSpec package `config/` files to the active QwenPaw workspace root - and package `skills/` to workspace skills, then reconcile QwenPaw `skill.json`. +- Apply AgentSpec package `config/` files to the active QwenPaw workspace root, + copy package `skills/` to workspace skills, and refresh/enable them through + the public Skill API. `desired.mcpServers` and package `mcp.json` are intentionally separate: -- `desired.mcpServers` is controller-projected runtime config and updates - `config/mcporter.json` for the `mcporter` CLI. -- Package `mcp.json` configures QwenPaw-native MCP clients. Its canonical shape +- `desired.mcpServers` is controller-projected runtime config and configures + QwenPaw-native MCP clients through the public API. +- Package `mcp.json` also configures QwenPaw-native MCP clients. Its canonical shape matches mcporter config: `{"mcpServers": {"name": {...}}}`. Legacy package shapes (`{"clients": {...}}`, `{"mcp": {"clients": {...}}}`, and top-level server maps) are still read for compatibility. @@ -75,15 +81,16 @@ controller-projected desired state. - Keep controller reporting as a bypass path: report failures are logged but do not block storage sync, desired-state apply, or QwenPaw runtime loops. -### 1.5 Matrix Channel Overlay +### 1.5 AgentTeams Matrix Channel -- Apply Matrix channel configuration and access control from `runtime.yaml` - through the desired-state update loop. -- Keep runtime behavior fixes in the QwenPaw Matrix overlay, not in TeamHarness - hooks. -- The overlay preserves QwenPaw Matrix support while adding AgentTeams startup - readiness, first-sync stability, invite auto-join, and visible Matrix mention - compatibility. +- The `agentteams-matrix-channel` plugin registers the independent + `agentteams_matrix` key through `BaseChannel` and `register_channel()`. +- It preserves AgentTeams room, DM, mention, thread, attachment, long-message, + typing, streaming, tool/thinking, TaskRoom, `NO_REPLY`, stop, and readiness + behavior. +- The image does not copy files over QwenPaw's built-in Matrix implementation. +- Matrix channel configuration and ACL come only from `runtime.yaml` and are + applied by the desired-state update loop through public APIs. The daemon does not implement TeamHarness collaboration semantics. It triggers runtime apply and storage persistence, then delegates TeamHarness-specific @@ -109,10 +116,11 @@ QwenPaw DingTalk channel. ## 2. Plugin Adapter -The default plugin adapters live under `plugins/teamharness/adapters/qwenpaw/` -and `plugins/workerflow/adapters/qwenpaw/`. They are installed into an -image-local QwenPaw working directory during Docker build, then prepared in the -runtime QwenPaw working directory on startup and `runtime.yaml` reapply. +The default plugin adapters live under `plugins/teamharness/adapters/qwenpaw/`, +`plugins/workerflow/adapters/qwenpaw/`, and +`plugins/agentteams-matrix-channel/`. They are installed into an image-local +QwenPaw working directory during Docker build, then prepared in the runtime +QwenPaw working directory on startup and `runtime.yaml` reapply. ### 2.1 Plugin Package @@ -120,19 +128,23 @@ runtime QwenPaw working directory on startup and `runtime.yaml` reapply. QwenPaw plugins. - Install the packages through `qwenpaw plugin install --force` during Docker image build. -- Keep the QwenPaw plugin ids as `teamharness` and `workerflow`. +- Keep the QwenPaw plugin ids as `teamharness`, `workerflow`, and + `agentteams-matrix-channel`. +- Require QwenPaw `>=2.0.1,<2.1.0` for all three plugins. ### 2.2 TeamHarness Assets - Merge TeamHarness role prompts into workspace `TEAMS.md`. -- Register TeamHarness skills for QwenPaw. -- Register the TeamHarness MCP server for QwenPaw. +- Register TeamHarness prompt sections, skills, middleware, runtime hooks, and + HTTP routes through the public plugin API. +- Configure TeamHarness and WorkerFlow MCP servers and allow policies through + the public MCP and MCP Policy APIs. -### 2.3 Runtime Wrappers +### 2.3 Public Plugin Hooks -- Install `TEAMS.md` into each QwenPaw agent workspace and include it in the - prompt file list. -- Redact sensitive tool output through the QwenPaw adapter sanitizer. +- Install TeamHarness and WorkerFlow skills through public skill providers. +- Redact sensitive tool output through registered middleware. +- Record TeamHarness dispatch traces through public runtime hooks. - Keep the sanitizer adapter-private. TeamHarness base plugin does not define runtime-neutral top-level hooks. @@ -157,9 +169,9 @@ local image. Without that override, the scripts build a fresh image. readiness, TeamHarness adapter health, and local heartbeat readiness. - `test-sync-minio.sh` verifies startup mirror and background push boundaries against MinIO. -- `test-update-runtime-config.sh` verifies `runtime.yaml` hot apply for model, - MCP, Matrix channel policy, and AgentSpec package changes without restarting - the worker container. +- `test-update-runtime-config.sh` verifies API-first `runtime.yaml` hot apply + for model, MCP, `agentteams_matrix` channel policy, and AgentSpec package + changes without restarting the worker container. ### Real Image And Model Test From 46fff1591ba3c160d5b2dcb34c7eb2f2f8f5f1a1 Mon Sep 17 00:00:00 2001 From: yuhuafeng Date: Mon, 27 Jul 2026 13:27:40 +0800 Subject: [PATCH 19/37] fix(qwenpaw): use 2.0 channel display contract --- .../agentteams_matrix/channel.py | 15 ++++++++++++--- qwenpaw/tests/test_matrix_plugin.py | 10 ++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/plugins/agentteams-matrix-channel/agentteams_matrix/channel.py b/plugins/agentteams-matrix-channel/agentteams_matrix/channel.py index 45c1109f8..b279103a4 100644 --- a/plugins/agentteams-matrix-channel/agentteams_matrix/channel.py +++ b/plugins/agentteams-matrix-channel/agentteams_matrix/channel.py @@ -3965,9 +3965,14 @@ def _is_message_event(self, message_type: Any) -> bool: return _enum_name(message_type) == "MESSAGE" def _thread_content_parts(self, event: Any) -> List[Any]: - """Render event for thread display, bypassing filter_tool_messages.""" + """Render event for thread display with tool messages enabled.""" from dataclasses import replace as dc_replace - style = dc_replace(self._render_style, filter_tool_messages=False) + display_config = dc_replace( + self._render_style.display_config, + show_tool_calls=True, + show_tool_results=True, + ) + style = dc_replace(self._render_style, display_config=display_config) renderer = self._renderer.__class__(style) return renderer.message_to_parts(event) @@ -3989,7 +3994,11 @@ def _visible_final_text(self, text: str) -> str: def _tool_output_media_parts(self, event: Any) -> List[Any]: """Extract media-only parts from a tool output event.""" from dataclasses import replace as dc_replace - style = dc_replace(self._render_style, filter_tool_messages=True) + display_config = dc_replace( + self._render_style.display_config, + show_tool_results=False, + ) + style = dc_replace(self._render_style, display_config=display_config) renderer = self._renderer.__class__(style) parts = renderer.message_to_parts(event) return [p for p in parts if getattr(p, "type", None) != ContentType.TEXT] diff --git a/qwenpaw/tests/test_matrix_plugin.py b/qwenpaw/tests/test_matrix_plugin.py index beb419876..14cdfbc57 100644 --- a/qwenpaw/tests/test_matrix_plugin.py +++ b/qwenpaw/tests/test_matrix_plugin.py @@ -28,3 +28,13 @@ def test_matrix_plugin_targets_qwenpaw_2_and_does_not_patch_builtin_matrix(): assert manifest["qwenpaw_version"] == {"min": "2.0.1", "max": "2.1.0"} assert "qwenpaw/app/channels/matrix/channel.py" not in dockerfile assert "/opt/agentteams/plugins/agentteams-matrix-channel" in dockerfile + + +def test_matrix_plugin_uses_qwenpaw_2_display_config_renderer_contract(): + source = (PLUGIN / "agentteams_matrix" / "channel.py").read_text(encoding="utf-8") + + assert "filter_tool_messages" not in source + assert "self._render_style.display_config" in source + assert "display_config=display_config" in source + assert "show_tool_calls=True" in source + assert "show_tool_results=False" in source From ff860205eafe095104e3d2c972e0dd66a324319d Mon Sep 17 00:00:00 2001 From: yuhuafeng Date: Mon, 27 Jul 2026 14:22:51 +0800 Subject: [PATCH 20/37] fix(qwenpaw): accept middleware keyword contract --- .../teamharness/adapters/qwenpaw/plugin.py | 3 ++- .../adapters/qwenpaw/test_adapter.py | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/plugins/teamharness/adapters/qwenpaw/plugin.py b/plugins/teamharness/adapters/qwenpaw/plugin.py index 48d6e2bc8..ca8c3477f 100644 --- a/plugins/teamharness/adapters/qwenpaw/plugin.py +++ b/plugins/teamharness/adapters/qwenpaw/plugin.py @@ -75,10 +75,11 @@ def _sanitizer_factory(_ctx: Any, _agent_config: Any): class TeamHarnessSanitizer(MiddlewareBase): async def on_acting( self, - _agent: Any, + agent: Any, input_kwargs: dict[str, Any], next_handler: Callable[..., AsyncGenerator[Any, None]], ) -> AsyncGenerator[Any, None]: + del agent async for item in next_handler(**input_kwargs): _sanitize(item) yield item diff --git a/plugins/tests/teamharness/adapters/qwenpaw/test_adapter.py b/plugins/tests/teamharness/adapters/qwenpaw/test_adapter.py index da505e310..2b01c4d92 100644 --- a/plugins/tests/teamharness/adapters/qwenpaw/test_adapter.py +++ b/plugins/tests/teamharness/adapters/qwenpaw/test_adapter.py @@ -1,5 +1,6 @@ """QwenPaw 2 public plugin API contract tests for TeamHarness.""" +import asyncio import importlib.util from pathlib import Path import sys @@ -70,6 +71,32 @@ def test_sanitizer_redacts_tool_output(monkeypatch): assert value["content"][0]["text"] == "token=[REDACTED]" +def test_sanitizer_accepts_qwenpaw_2_middleware_keywords(monkeypatch): + module = load_plugin() + middleware_module = types.ModuleType("agentscope.middleware") + middleware_module.MiddlewareBase = object + agentscope_module = types.ModuleType("agentscope") + agentscope_module.middleware = middleware_module + monkeypatch.setitem(sys.modules, "agentscope", agentscope_module) + monkeypatch.setitem(sys.modules, "agentscope.middleware", middleware_module) + middleware = module._sanitizer_factory(None, None) + + async def next_handler(**_kwargs): + yield {"text": "ok"} + + async def collect(): + return [ + item + async for item in middleware.on_acting( + agent=object(), + input_kwargs={"tool_call": object()}, + next_handler=next_handler, + ) + ] + + assert asyncio.run(collect()) == [{"text": "ok"}] + + def test_team_prompt_reads_packaged_contract(): module = load_plugin() assert "TeamHarness" in module._team_prompt(None) From f2f72523f89324bd6d894db1bb308bd18cc055b8 Mon Sep 17 00:00:00 2001 From: yuhuafeng Date: Mon, 27 Jul 2026 15:19:55 +0800 Subject: [PATCH 21/37] fix(qwenpaw): apply MCP policy after driver activation --- qwenpaw/src/qwenpaw_worker/api.py | 28 ++++++++++++++++++++++++++ qwenpaw/src/qwenpaw_worker/worker.py | 1 + qwenpaw/tests/test_api.py | 28 ++++++++++++++++++++++++++ qwenpaw/tests/test_worker_lifecycle.py | 12 +++++++++++ 4 files changed, 69 insertions(+) diff --git a/qwenpaw/src/qwenpaw_worker/api.py b/qwenpaw/src/qwenpaw_worker/api.py index 4ef9551d3..65b9a3ef4 100644 --- a/qwenpaw/src/qwenpaw_worker/api.py +++ b/qwenpaw/src/qwenpaw_worker/api.py @@ -244,6 +244,34 @@ def get_mcp_policy(self, client_key: str) -> dict[str, Any]: f"/api/mcp/policy/{urllib.parse.quote(client_key, safe='')}", ) + def list_mcp_tools(self, client_key: str) -> list[dict[str, Any]]: + return self._request( + "GET", + f"/api/mcp/tools/{urllib.parse.quote(client_key, safe='')}", + ) + + def wait_for_mcp_tools( + self, + client_key: str, + *, + timeout: float = 30, + interval: float = 0.5, + ) -> list[dict[str, Any]]: + deadline = time.monotonic() + timeout + last_error: Exception | None = None + while time.monotonic() < deadline: + try: + tools = self.list_mcp_tools(client_key) + if tools: + return tools + except QwenPawApiError as exc: + last_error = exc + time.sleep(interval) + raise QwenPawApiError( + f"QwenPaw MCP client {client_key} did not become callable: " + f"{last_error or 'no tools returned'}", + ) + def put_mcp_policy( self, client_key: str, diff --git a/qwenpaw/src/qwenpaw_worker/worker.py b/qwenpaw/src/qwenpaw_worker/worker.py index 0645aec20..26fc47265 100644 --- a/qwenpaw/src/qwenpaw_worker/worker.py +++ b/qwenpaw/src/qwenpaw_worker/worker.py @@ -750,6 +750,7 @@ def _configure_builtin_plugin_mcp_policies(self) -> None: "tool_overrides": [], } for plugin_id in ("teamharness", "workerflow"): + self.api_client.wait_for_mcp_tools(plugin_id) self.api_client.put_mcp_policy(plugin_id, allow_policy) async def _heartbeat_probe_loop(self) -> None: diff --git a/qwenpaw/tests/test_api.py b/qwenpaw/tests/test_api.py index 92dfb6f69..beb17eafb 100644 --- a/qwenpaw/tests/test_api.py +++ b/qwenpaw/tests/test_api.py @@ -26,6 +26,7 @@ class _ApiHandler(BaseHTTPRequestHandler): "unmanaged_rules_count": 0, } mcp = {} + mcp_tools_unavailable = 0 toggle_conflicts = 0 def log_message(self, _format, *_args): @@ -59,6 +60,13 @@ def do_GET(self): if self.path == "/api/mcp/policy/teamharness": self._reply(200, type(self).mcp_policy) return + if self.path == "/api/mcp/tools/teamharness": + if type(self).mcp_tools_unavailable: + type(self).mcp_tools_unavailable -= 1 + self._reply(503, {"detail": "driver not active yet"}) + return + self._reply(200, [{"name": "taskflow", "enabled": True}]) + return if self.path == "/api/mcp": self._reply(200, list(type(self).mcp.values())) return @@ -163,6 +171,7 @@ def api_url(): "unmanaged_rules_count": 0, } _ApiHandler.mcp = {} + _ApiHandler.mcp_tools_unavailable = 0 _ApiHandler.toggle_conflicts = 0 server = ThreadingHTTPServer(("127.0.0.1", 0), _ApiHandler) thread = Thread(target=server.serve_forever, daemon=True) @@ -247,6 +256,25 @@ def test_put_mcp_policy_uses_public_api_and_reads_back(api_url): assert result["default_effect"] == "allow" +def test_list_mcp_tools_activates_driver_through_public_api(api_url): + client = QwenPawApiClient(api_url) + + assert client.list_mcp_tools("teamharness") == [ + {"name": "taskflow", "enabled": True}, + ] + + +def test_wait_for_mcp_tools_retries_until_driver_is_active(api_url): + _ApiHandler.mcp_tools_unavailable = 2 + client = QwenPawApiClient(api_url) + + assert client.wait_for_mcp_tools( + "teamharness", + timeout=1, + interval=0.01, + ) == [{"name": "taskflow", "enabled": True}] + + def test_mcp_create_update_delete_each_reads_back(api_url): client = QwenPawApiClient(api_url) diff --git a/qwenpaw/tests/test_worker_lifecycle.py b/qwenpaw/tests/test_worker_lifecycle.py index 7a93a7bd3..d78b59ca4 100644 --- a/qwenpaw/tests/test_worker_lifecycle.py +++ b/qwenpaw/tests/test_worker_lifecycle.py @@ -518,6 +518,7 @@ def test_builtin_plugin_mcp_is_reconciled_through_api(tmp_path: Path) -> None: worker = Worker(_config(tmp_path)) created = {} policies = {} + events = [] class Api: def list_mcp(self): @@ -529,7 +530,12 @@ def create_mcp(self, key, payload): def update_mcp(self, key, payload): raise AssertionError(f"unexpected update: {key} {payload}") + def wait_for_mcp_tools(self, key): + events.append(("tools", key)) + return [{"name": "ready", "enabled": True}] + def put_mcp_policy(self, key, payload): + events.append(("policy", key)) policies[key] = payload worker.api_client = Api() @@ -538,6 +544,12 @@ def put_mcp_policy(self, key, payload): assert set(created) == {"teamharness", "workerflow"} assert set(policies) == {"teamharness", "workerflow"} + assert events == [ + ("tools", "teamharness"), + ("policy", "teamharness"), + ("tools", "workerflow"), + ("policy", "workerflow"), + ] assert all(policy["default_effect"] == "allow" for policy in policies.values()) assert created["teamharness"]["transport"] == "stdio" assert created["teamharness"]["args"][0].endswith( From ec4bf4f39b12b8cc394c870bf05fb6bc30424968 Mon Sep 17 00:00:00 2001 From: yuhuafeng Date: Mon, 27 Jul 2026 16:28:26 +0800 Subject: [PATCH 22/37] fix(qwenpaw): reconcile team storage updates --- plugins/teamharness/mcp/server.py | 6 +-- .../tests/teamharness/test_mcp_workspace.py | 36 +++++++++++++++++ qwenpaw/src/qwenpaw_worker/update.py | 5 +++ qwenpaw/src/qwenpaw_worker/worker.py | 18 +++++++++ qwenpaw/tests/test_update.py | 39 +++++++++++++++++++ qwenpaw/tests/test_worker_lifecycle.py | 35 +++++++++++++++++ 6 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 plugins/tests/teamharness/test_mcp_workspace.py diff --git a/plugins/teamharness/mcp/server.py b/plugins/teamharness/mcp/server.py index e18ea2673..ca29da258 100644 --- a/plugins/teamharness/mcp/server.py +++ b/plugins/teamharness/mcp/server.py @@ -2351,13 +2351,13 @@ def _with_storage_root(prefix: str) -> str: def _default_workspace_dir() -> str: """Derive workspace dir from environment (set by qwenpaw-worker / copaw-worker).""" + shared_dir = os.getenv("TEAMHARNESS_SHARED_DIR", "").strip() or os.getenv("AGENTTEAMS_SHARED_DIR", "").strip() + if shared_dir: + return str(Path(shared_dir).parent) for env_key in ("QWENPAW_WORKING_DIR", "COPAW_WORKING_DIR"): working_dir = os.getenv(env_key, "").strip() if working_dir: return str(Path(working_dir) / "workspaces" / "default") - shared_dir = os.getenv("TEAMHARNESS_SHARED_DIR", "").strip() or os.getenv("AGENTTEAMS_SHARED_DIR", "").strip() - if shared_dir: - return str(Path(shared_dir).parent) return "" diff --git a/plugins/tests/teamharness/test_mcp_workspace.py b/plugins/tests/teamharness/test_mcp_workspace.py new file mode 100644 index 000000000..f9d89610e --- /dev/null +++ b/plugins/tests/teamharness/test_mcp_workspace.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +import sys + + +REPO_ROOT = Path(__file__).resolve().parents[3] +MCP_DIR = REPO_ROOT / "plugins" / "teamharness" / "mcp" + + +def _load_server(): + spec = importlib.util.spec_from_file_location( + "teamharness_mcp_server_workspace_test", + MCP_DIR / "server.py", + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.path.insert(0, str(MCP_DIR)) + try: + spec.loader.exec_module(module) + finally: + sys.path.remove(str(MCP_DIR)) + return module + + +def test_default_workspace_prefers_worker_shared_dir( + tmp_path: Path, + monkeypatch, +) -> None: + server = _load_server() + shared_dir = tmp_path / "teams" / "demo-team" / "shared" + monkeypatch.setenv("TEAMHARNESS_SHARED_DIR", str(shared_dir)) + monkeypatch.setenv("QWENPAW_WORKING_DIR", str(tmp_path / "agent" / ".qwenpaw")) + + assert server._default_workspace_dir() == str(shared_dir.parent) diff --git a/qwenpaw/src/qwenpaw_worker/update.py b/qwenpaw/src/qwenpaw_worker/update.py index 72d307816..131d02aa6 100644 --- a/qwenpaw/src/qwenpaw_worker/update.py +++ b/qwenpaw/src/qwenpaw_worker/update.py @@ -445,6 +445,7 @@ def changed_from(self, previous: "MemberRuntimeConfig") -> bool: or self.desired_identity != previous.desired_identity or self.team_context_identity != previous.team_context_identity or self.credential_runtime_identity != previous.credential_runtime_identity + or _stable_json(self.storage) != _stable_json(previous.storage) ) @@ -1269,12 +1270,14 @@ def __init__( runtime_config_pull: Optional[Callable[[], None]] = None, team_context_renderer: Optional[Callable[[MemberRuntimeConfig], str]] = None, api_client: Optional[QwenPawApiClient] = None, + runtime_reconcile: Optional[Callable[[MemberRuntimeConfig], None]] = None, ) -> None: self.config = config self.adapter_apply = adapter_apply self.runtime_config_pull = runtime_config_pull self.team_context_renderer = team_context_renderer self.api_client = api_client + self.runtime_reconcile = runtime_reconcile self.package_manager = package_manager or AgentPackageManager( config.qwenpaw_working_dir / "agent-packages", workspace_dir=config.default_workspace_dir, @@ -1333,6 +1336,8 @@ def apply_once( _duration_ms(started_at), ) self._apply_member_identity(config) + if self.runtime_reconcile is not None: + self.runtime_reconcile(config) self._apply_model(config) self._apply_mcp_servers(config) self._apply_matrix_channel(config) diff --git a/qwenpaw/src/qwenpaw_worker/worker.py b/qwenpaw/src/qwenpaw_worker/worker.py index 26fc47265..4f83075c9 100644 --- a/qwenpaw/src/qwenpaw_worker/worker.py +++ b/qwenpaw/src/qwenpaw_worker/worker.py @@ -73,6 +73,7 @@ def __init__(self, config: WorkerConfig) -> None: config=config, adapter_apply=self._apply_runtime_adapter, api_client=self.api_client, + runtime_reconcile=self._reconcile_runtime_storage, ) self._process: Optional[asyncio.subprocess.Process] = None self._heartbeat_probe_task: Optional[asyncio.Task] = None @@ -342,6 +343,23 @@ def _apply_runtime_storage(self, runtime_config) -> None: shared_dir, ) self.sync.mirror_prefix(shared_prefix, shared_dir) + else: + os.environ.pop("AGENTTEAMS_SHARED_STORAGE_PREFIX", None) + + def _reconcile_runtime_storage(self, runtime_config: MemberRuntimeConfig) -> None: + shared_prefix = self._runtime_shared_prefix(runtime_config) + shared_dir = self._local_shared_dir_for_prefix(shared_prefix) + if self._workspace_shared_dir is None: + self._apply_runtime_storage(runtime_config) + self._link_workspace_shared() + return + current_prefix = os.getenv("AGENTTEAMS_SHARED_STORAGE_PREFIX", "").strip() or self.config.shared_prefix + if self._workspace_shared_dir == shared_dir and current_prefix == shared_prefix: + return + self._apply_runtime_storage(runtime_config) + self._link_workspace_shared() + self._configure_builtin_plugin_mcp_clients() + self._configure_builtin_plugin_mcp_policies() def _runtime_shared_prefix(self, runtime_config) -> str: storage = getattr(runtime_config, "storage", {}) or {} diff --git a/qwenpaw/tests/test_update.py b/qwenpaw/tests/test_update.py index 8be7b0756..0a7008262 100644 --- a/qwenpaw/tests/test_update.py +++ b/qwenpaw/tests/test_update.py @@ -303,6 +303,45 @@ def apply(self, runtime_config: MemberRuntimeConfig): assert updater.current_config.generation == "2" +def test_runtime_updater_reconciles_storage_only_change( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config = _config(tmp_path) + reconciled: list[tuple[str, str]] = [] + monkeypatch.setenv("AGENTTEAMS_AGENT_ROLE", "standalone") + updater = _runtime_updater( + config=config, + package_manager=_NoopPackageManager(), + runtime_reconcile=lambda runtime: reconciled.append( + (runtime.storage["sharedPrefix"], os.environ["AGENTTEAMS_AGENT_ROLE"]), + ), + ) + updater.current_config = MemberRuntimeConfig( + path=config.runtime_config_path, + raw={ + "metadata": {"generation": "2"}, + "member": {"runtime": "qwenpaw"}, + "storage": {"sharedPrefix": "shared"}, + }, + ) + + result = updater.apply_once( + runtime_config=MemberRuntimeConfig( + path=config.runtime_config_path, + raw={ + "metadata": {"generation": "2"}, + "member": {"runtime": "qwenpaw", "role": "team_leader"}, + "storage": {"sharedPrefix": "teams/demo-team/shared"}, + }, + ), + reapply_adapter=False, + ) + + assert result.changed is True + assert reconciled == [("teams/demo-team/shared", "team_leader")] + + def test_runtime_updater_logs_safe_apply_summary_without_sensitive_values( tmp_path: Path, caplog: pytest.LogCaptureFixture, diff --git a/qwenpaw/tests/test_worker_lifecycle.py b/qwenpaw/tests/test_worker_lifecycle.py index d78b59ca4..5b31d9f2d 100644 --- a/qwenpaw/tests/test_worker_lifecycle.py +++ b/qwenpaw/tests/test_worker_lifecycle.py @@ -150,6 +150,41 @@ def test_link_workspace_shared_points_to_canonical_shared(tmp_path: Path) -> Non assert (config.shared_dir / "tasks" / "task-1" / "workspace" / "note.txt").read_text(encoding="utf-8") == "ready\n" +def test_runtime_storage_change_relinks_shared_and_refreshes_builtin_mcp( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config = _config(tmp_path) + worker = Worker(config) + mirrored: list[tuple[str, Path]] = [] + refreshed: list[str] = [] + worker.sync = types.SimpleNamespace( + mirror_prefix=lambda prefix, local_dir: mirrored.append((prefix, local_dir)), + ) + worker._prepare_env() + worker._workspace_shared_dir = config.shared_dir + worker._link_workspace_shared() + monkeypatch.setattr(worker, "_configure_builtin_plugin_mcp_clients", lambda: refreshed.append("clients")) + monkeypatch.setattr(worker, "_configure_builtin_plugin_mcp_policies", lambda: refreshed.append("policies")) + runtime = MemberRuntimeConfig( + path=config.runtime_config_path, + raw={ + "member": {"runtime": "qwenpaw"}, + "storage": {"sharedPrefix": "teams/demo-team/shared"}, + }, + ) + + worker._reconcile_runtime_storage(runtime) + + team_shared = tmp_path / "teams" / "demo-team" / "shared" + workspace_shared = config.default_workspace_dir / "shared" + assert mirrored == [("teams/demo-team/shared", team_shared)] + assert workspace_shared.resolve() == team_shared.resolve() + assert os.environ["TEAMHARNESS_SHARED_DIR"] == str(team_shared) + assert os.environ["AGENTTEAMS_SHARED_STORAGE_PREFIX"] == "teams/demo-team/shared" + assert refreshed == ["clients", "policies"] + + def _legacy_configure_qwenpaw_runtime_uses_workspace_teams_prompt( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, From fb81a4367f875f5827267772cb21587762871d2d Mon Sep 17 00:00:00 2001 From: yuhuafeng Date: Mon, 27 Jul 2026 17:26:56 +0800 Subject: [PATCH 23/37] fix(qwenpaw): pass storage contract to plugin MCP --- qwenpaw/src/qwenpaw_worker/worker.py | 11 +++++++++++ qwenpaw/tests/test_worker_lifecycle.py | 18 +++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/qwenpaw/src/qwenpaw_worker/worker.py b/qwenpaw/src/qwenpaw_worker/worker.py index 4f83075c9..bf46e5892 100644 --- a/qwenpaw/src/qwenpaw_worker/worker.py +++ b/qwenpaw/src/qwenpaw_worker/worker.py @@ -294,6 +294,14 @@ def _prepare_env(self) -> None: os.environ["AGENTTEAMS_AGENT_HOME"] = str(self.config.worker_home) os.environ["AGENTTEAMS_WORKER_HOME"] = str(self.config.worker_home) os.environ.setdefault("AGENTTEAMS_WORKER_NAME", self.config.worker_name) + os.environ["AGENTTEAMS_FS_ENDPOINT"] = self.config.fs_endpoint + os.environ["AGENTTEAMS_FS_ACCESS_KEY"] = self.config.fs_access_key + os.environ["AGENTTEAMS_FS_SECRET_KEY"] = self.config.fs_secret_key + os.environ["AGENTTEAMS_FS_BUCKET"] = self.config.fs_bucket + os.environ.setdefault( + "AGENTTEAMS_STORAGE_PREFIX", + f"agentteams/{self.config.fs_bucket}", + ) os.environ["QWENPAW_WORKING_DIR"] = str(self.config.qwenpaw_working_dir) os.environ["AGENT_WORKSPACE"] = str(self.config.default_workspace_dir) os.environ["AGENTTEAMS_SHARED_DIR"] = str(self.config.shared_dir) @@ -746,9 +754,12 @@ def _configure_builtin_plugin_mcp_clients(self) -> None: "AGENTTEAMS_WORKER_ROLE", "AGENTTEAMS_AGENT_ROLE", "AGENTTEAMS_WORKER_NAME", + "AGENTTEAMS_STORAGE_PREFIX", "AGENTTEAMS_SHARED_STORAGE_PREFIX", "AGENTTEAMS_FS_BUCKET", "AGENTTEAMS_FS_ENDPOINT", + "AGENTTEAMS_FS_ACCESS_KEY", + "AGENTTEAMS_FS_SECRET_KEY", "QWENPAW_WORKING_DIR", ) if (value := os.getenv(name, "").strip()) diff --git a/qwenpaw/tests/test_worker_lifecycle.py b/qwenpaw/tests/test_worker_lifecycle.py index 5b31d9f2d..0f592ebec 100644 --- a/qwenpaw/tests/test_worker_lifecycle.py +++ b/qwenpaw/tests/test_worker_lifecycle.py @@ -549,8 +549,13 @@ def test_prepare_env_exposes_agent_workspace(tmp_path: Path, monkeypatch: pytest assert os.environ["AGENT_WORKSPACE"] == str(config.default_workspace_dir) -def test_builtin_plugin_mcp_is_reconciled_through_api(tmp_path: Path) -> None: +def test_builtin_plugin_mcp_is_reconciled_through_api( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: worker = Worker(_config(tmp_path)) + monkeypatch.setenv("AGENTTEAMS_STORAGE_PREFIX", "agentteams/agentteams-storage") + worker._prepare_env() created = {} policies = {} events = [] @@ -590,6 +595,17 @@ def put_mcp_policy(self, key, payload): assert created["teamharness"]["args"][0].endswith( "/plugins/teamharness/teamharness/mcp/server.py" ) + expected_storage_env = { + "TEAMHARNESS_RUNTIME_CONFIG": str(worker.config.runtime_config_path), + "TEAMHARNESS_SHARED_DIR": str(worker.config.shared_dir), + "AGENTTEAMS_STORAGE_PREFIX": "agentteams/agentteams-storage", + "AGENTTEAMS_FS_BUCKET": "agentteams-storage", + "AGENTTEAMS_FS_ENDPOINT": "http://minio:9000", + "AGENTTEAMS_FS_ACCESS_KEY": "key", + "AGENTTEAMS_FS_SECRET_KEY": "secret", + "QWENPAW_WORKING_DIR": str(worker.config.qwenpaw_working_dir), + } + assert expected_storage_env.items() <= created["teamharness"]["env"].items() def test_runtime_updater_uses_default_workspace_for_package_materialization( From 2c976b980620f359d28a667a42cbb95ea5e1e835 Mon Sep 17 00:00:00 2001 From: yuhuafeng Date: Mon, 27 Jul 2026 18:53:33 +0800 Subject: [PATCH 24/37] test(qwenpaw): validate teamharness through public APIs --- ...test-26-qwenpaw-teamharness-plugin-mode.sh | 139 +++++++++++------- 1 file changed, 87 insertions(+), 52 deletions(-) diff --git a/tests/test-26-qwenpaw-teamharness-plugin-mode.sh b/tests/test-26-qwenpaw-teamharness-plugin-mode.sh index c9b387bba..c8c1c4070 100755 --- a/tests/test-26-qwenpaw-teamharness-plugin-mode.sh +++ b/tests/test-26-qwenpaw-teamharness-plugin-mode.sh @@ -274,18 +274,36 @@ import os import subprocess import sys from pathlib import Path +from urllib.request import urlopen for item in Path("/proc/1/environ").read_bytes().split(b"\0"): if item and b"=" in item: key, value = item.split(b"=", 1) os.environ.setdefault(key.decode(), value.decode()) -workspace = Path(os.environ["QWENPAW_WORKING_DIR"]) / "workspaces" / "default" -agent_config = json.loads((workspace / "agent.json").read_text(encoding="utf-8")) -client = agent_config["mcp"]["clients"]["teamharness"] +with urlopen("http://127.0.0.1:8088/api/mcp/teamharness") as response: + client = json.load(response) + +cwd = Path(client["cwd"]) +working_dir = cwd.parents[2] +workspace = working_dir / "workspaces" / "default" +derived_env = { + "QWENPAW_WORKING_DIR": str(working_dir), + "TEAMHARNESS_RUNTIME_CONFIG": str(working_dir.parent / "runtime" / "runtime.yaml"), + "TEAMHARNESS_SHARED_DIR": str((workspace / "shared").resolve()), + "AGENTTEAMS_STORAGE_PREFIX": f"agentteams/{os.environ.get('AGENTTEAMS_FS_BUCKET', 'agentteams-storage')}", +} env = dict(os.environ) -env.update({str(k): str(v) for k, v in (client.get("env") or {}).items()}) +for key, value in (client.get("env") or {}).items(): + key = str(key) + value = str(value) + if key in os.environ: + env[key] = os.environ[key] + elif key in derived_env: + env[key] = derived_env[key] + elif "*" not in value: + env[key] = value request = { "jsonrpc": "2.0", "id": 1, @@ -860,7 +878,7 @@ assert_eq "standalone" "$(_env_value "${WORKER_ENV}" AGENTTEAMS_WORKER_ROLE)" "M log_section "QwenPaw Plugin Mode" for container in "${LEADER_CONTAINER}" "${WORKER_CONTAINER}"; do - if _wait_agent_api_ok "${container}" GET /api/teamharness/health '.ok == true and .plugin == "teamharness" and .adapter == "qwenpaw"' 240 >/dev/null; then + if _wait_agent_api_ok "${container}" GET /api/teamharness/health '.ok == true and .plugin == "teamharness" and .adapter == "qwenpaw-2"' 240 >/dev/null; then log_pass "${container} TeamHarness health endpoint is healthy" else log_fail "${container} TeamHarness health endpoint did not become healthy" @@ -943,16 +961,21 @@ for container in "${LEADER_CONTAINER}" "${WORKER_CONTAINER}"; do log_fail "${container} agent prompt files missing TEAMS.md" fi - if docker exec "${container}" sh -c "jq -e '.mcp.clients.teamharness.enabled == true and .mcp.clients.teamharness.command and (.mcp.clients.teamharness.args | length > 0)' '${workspace}/agent.json'" >/dev/null 2>&1; then - log_pass "${container} agent config includes TeamHarness MCP client" + TEAMHARNESS_MCP=$(_agent_api "${container}" GET /api/mcp/teamharness 2>/dev/null || echo "{}") + TEAMHARNESS_TOOLS=$(_agent_api "${container}" GET /api/mcp/tools/teamharness 2>/dev/null || echo "[]") + if echo "${TEAMHARNESS_MCP}" | jq -e '.enabled == true and .transport == "stdio" and .command and (.args | length > 0)' >/dev/null 2>&1 && \ + echo "${TEAMHARNESS_TOOLS}" | jq -e '([.[].name] | index("health") and index("filesync") and index("taskflow") and index("projectflow"))' >/dev/null 2>&1; then + log_pass "${container} QwenPaw API exposes callable TeamHarness MCP" else - log_fail "${container} agent config missing TeamHarness MCP client" + log_fail "${container} QwenPaw API missing callable TeamHarness MCP" fi - if docker exec "${container}" sh -c "jq -e --arg name '${TEST_PACKAGE_MCP_NAME}' --arg url '${TEST_PACKAGE_MCP_URL}' '.mcp.clients[\$name].url == \$url and (.mcp.clients[\$name].enabled != false)' '${workspace}/agent.json' && test ! -f '${workspace}/mcp.json'" >/dev/null 2>&1; then - log_pass "${container} agent config embeds AgentSpec package MCP" + PACKAGE_MCP=$(_agent_api "${container}" GET "/api/mcp/${TEST_PACKAGE_MCP_NAME}" 2>/dev/null || echo "{}") + if echo "${PACKAGE_MCP}" | jq -e --arg url "${TEST_PACKAGE_MCP_URL}" '.url == $url and .enabled == true' >/dev/null 2>&1 && \ + docker exec "${container}" test ! -f "${workspace}/mcp.json" >/dev/null 2>&1; then + log_pass "${container} QwenPaw API exposes AgentSpec package MCP" else - log_fail "${container} agent config missing AgentSpec package MCP" + log_fail "${container} QwenPaw API missing AgentSpec package MCP" fi if docker exec "${container}" sh -c "grep -q 'TEST26 AgentSpec Package' '${workspace}/AGENTS.md' && grep -q '${TEST_TEAM}' '${workspace}/SOUL.md'" >/dev/null 2>&1; then @@ -988,34 +1011,33 @@ done _workspace_skill_check() { local container="$1" - local workspace="$2" - local required="$3" - local forbidden="$4" + local required_teamharness="$2" + local required_workerflow="$3" docker exec -i \ - -e TEST_WORKSPACE="${workspace}" \ - -e TEST_REQUIRED="${required}" \ - -e TEST_FORBIDDEN="${forbidden}" \ + -e TEST_REQUIRED_TEAMHARNESS="${required_teamharness}" \ + -e TEST_REQUIRED_WORKERFLOW="${required_workerflow}" \ "${container}" \ /opt/venv/qwenpaw/bin/python - <<'PY' 2>/dev/null | tail -n 1 import json import os -from pathlib import Path - -workspace = Path(os.environ["TEST_WORKSPACE"]) -required = [item for item in os.environ["TEST_REQUIRED"].split(",") if item] -forbidden = [item for item in os.environ["TEST_FORBIDDEN"].split(",") if item] -manifest = json.loads((workspace / "skill.json").read_text(encoding="utf-8")) -skills = manifest.get("skills") or {} -missing = [name for name in required if not (workspace / "skills" / name / "SKILL.md").is_file()] -disabled = [name for name in required if not skills.get(name, {}).get("enabled")] -unexpected = [name for name in forbidden if (workspace / "skills" / name).exists() or name in skills] +from urllib.request import urlopen + +with urlopen("http://127.0.0.1:8088/api/skills") as response: + skills = {item["name"]: item for item in json.load(response)} + +required_teamharness = [item for item in os.environ["TEST_REQUIRED_TEAMHARNESS"].split(",") if item] +required_workerflow = [item for item in os.environ["TEST_REQUIRED_WORKERFLOW"].split(",") if item] +missing = [ + name for name in required_teamharness + if skills.get(name, {}).get("source") != "plugin:teamharness" +] +missing.extend( + name for name in required_workerflow + if skills.get(name, {}).get("source") != "plugin:workerflow" +) problems = [] if missing: problems.append("missing:" + ",".join(missing)) -if disabled: - problems.append("disabled:" + ",".join(disabled)) -if unexpected: - problems.append("unexpected:" + ",".join(unexpected)) print("ok" if not problems else ";".join(problems)) PY } @@ -1029,21 +1051,29 @@ _workspace_runtime_projection_check() { -e TEST_MCP_NAME="${TEST_MCP_NAME}" \ -e TEST_MCP_URL="${TEST_MCP_URL}" \ -e TEST_MCP_TRANSPORT="${TEST_MCP_TRANSPORT}" \ + -e TEST_PACKAGE_MCP_NAME="${TEST_PACKAGE_MCP_NAME}" \ + -e TEST_PACKAGE_MCP_URL="${TEST_PACKAGE_MCP_URL}" \ "${container}" \ /opt/venv/qwenpaw/bin/python - <<'PY' 2>/dev/null | tail -n 1 import json import os from pathlib import Path +from urllib.request import urlopen workspace = Path(os.environ["TEST_WORKSPACE"]) model = os.environ["TEST_MODEL"] mcp_name = os.environ["TEST_MCP_NAME"] mcp_url = os.environ["TEST_MCP_URL"] mcp_transport = os.environ["TEST_MCP_TRANSPORT"] +package_mcp_name = os.environ["TEST_PACKAGE_MCP_NAME"] +package_mcp_url = os.environ["TEST_PACKAGE_MCP_URL"] + +def get(path): + with urlopen(f"http://127.0.0.1:8088{path}") as response: + return json.load(response) problems = [] -agent = json.loads((workspace / "agent.json").read_text(encoding="utf-8")) -active = agent.get("active_model") or {} +active = get("/api/models/active?scope=agent&agent_id=default").get("active_llm") or {} if active.get("provider_id") != "agentteams-gateway" or active.get("model") != model: problems.append("active_model") @@ -1051,17 +1081,24 @@ legacy_path = workspace / "mcporter-servers.json" if legacy_path.exists(): problems.append("legacy:mcporter-servers.json") -path = workspace / "config" / "mcporter.json" -if not path.is_file(): - problems.append("missing:config/mcporter.json") -else: - data = json.loads(path.read_text(encoding="utf-8")) - server = (data.get("mcpServers") or {}).get(mcp_name) or {} - if server.get("url") != mcp_url or server.get("transport") != mcp_transport: - problems.append("mcp:config/mcporter.json") - authorization = (server.get("headers") or {}).get("Authorization", "") - if not authorization.startswith("Bearer "): - problems.append("auth:config/mcporter.json") +mcp = {item["key"]: item for item in get("/api/mcp")} +server = mcp.get(mcp_name) or {} +if server.get("url") != mcp_url or server.get("transport") != mcp_transport: + problems.append("mcp:api") +authorization = (server.get("headers") or {}).get("Authorization", "") +if not authorization or "*" not in authorization: + problems.append("auth:api") +package_server = mcp.get(package_mcp_name) or {} +if package_server.get("url") != package_mcp_url or not package_server.get("enabled"): + problems.append("package_mcp:api") +for key in ("teamharness", "workerflow"): + client = mcp.get(key) or {} + if not client.get("enabled") or client.get("transport") != "stdio": + problems.append(f"{key}:api") + if get(f"/api/mcp/policy/{key}").get("default_effect") != "allow": + problems.append(f"{key}:policy") +if (workspace / "config" / "mcporter.json").exists(): + problems.append("legacy:config/mcporter.json") print("ok" if not problems else ";".join(problems)) PY @@ -1075,17 +1112,15 @@ assert_eq "ok" "${WORKER_RUNTIME_PROJECTION_CHECK}" "Worker applies controller m LEADER_SKILL_CHECK=$(_workspace_skill_check \ "${LEADER_CONTAINER}" \ - "${LEADER_DEFAULT_WS}" \ - "teamharness-communication,teamharness-file-sharing,teamharness-team-coordination,teamharness-project-management,teamharness-task-delegation" \ - "teamharness-task-execution") -assert_eq "ok" "${LEADER_SKILL_CHECK}" "Leader workspace enables TeamHarness role skills" + "communication,file-sharing,team-coordination,project-management,task-delegation,task-execution,mcporter" \ + "worker-internal-workflow") +assert_eq "ok" "${LEADER_SKILL_CHECK}" "Leader API exposes TeamHarness and WorkerFlow plugin skills" WORKER_SKILL_CHECK=$(_workspace_skill_check \ "${WORKER_CONTAINER}" \ - "${WORKER_DEFAULT_WS}" \ - "teamharness-communication,teamharness-file-sharing,teamharness-mcporter,teamharness-task-execution" \ - "teamharness-task-delegation") -assert_eq "ok" "${WORKER_SKILL_CHECK}" "Worker workspace enables TeamHarness role skills" + "communication,file-sharing,team-coordination,project-management,task-delegation,task-execution,mcporter" \ + "worker-internal-workflow") +assert_eq "ok" "${WORKER_SKILL_CHECK}" "Worker API exposes TeamHarness and WorkerFlow plugin skills" MCP_HEALTH=$(_worker_mcp_call health '{}' 2>/dev/null || echo "{}") if echo "${MCP_HEALTH}" | jq -e '.ok == true' >/dev/null 2>&1; then From ee4bd6049afa915f6d33811d6c0fb8d4d5a7136b Mon Sep 17 00:00:00 2001 From: yuhuafeng Date: Mon, 27 Jul 2026 19:29:06 +0800 Subject: [PATCH 25/37] test(qwenpaw): wait for API-first runtime convergence --- ...test-26-qwenpaw-teamharness-plugin-mode.sh | 44 ++++++++++++++++--- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/tests/test-26-qwenpaw-teamharness-plugin-mode.sh b/tests/test-26-qwenpaw-teamharness-plugin-mode.sh index c8c1c4070..5824ba323 100755 --- a/tests/test-26-qwenpaw-teamharness-plugin-mode.sh +++ b/tests/test-26-qwenpaw-teamharness-plugin-mode.sh @@ -424,6 +424,35 @@ _wait_workspace_marker() { return 1 } +_wait_workspace_team_context() { + local container="$1" + local path="$2" + local role="$3" + local timeout="${4:-120}" + local elapsed=0 + while [ "${elapsed}" -lt "${timeout}" ]; do + if docker exec "${container}" sh -c ' + path="$1" + role="$2" + team="$3" + leader="$4" + worker="$5" + worker_mxid="$6" + grep -Fq "member.role: ${role}" "${path}" && + grep -Fq "${team}" "${path}" && + grep -Fq "" "${path}" && + grep -Fq "runtimeName: ${leader}" "${path}" && + grep -Fq "runtimeName: ${worker}" "${path}" && + grep -Fq "matrixUserId: ${worker_mxid}" "${path}" + ' sh "${path}" "${role}" "${TEST_TEAM}" "${TEST_LEADER}" "${TEST_WORKER}" "${WORKER_MXID}" >/dev/null 2>&1; then + return 0 + fi + sleep 5 + elapsed=$((elapsed + 5)) + done + return 1 +} + _assert_runtime_yaml_has_no_secret_values() { local yaml="$1" local container="$2" @@ -943,15 +972,15 @@ for container in "${LEADER_CONTAINER}" "${WORKER_CONTAINER}"; do log_fail "${container} workspace TeamHarness prompt missing" fi - if docker exec "${container}" sh -c "grep -q 'member.role: ${role}' '${workspace}/TEAMS.md' && grep -q '${TEST_TEAM}' '${workspace}/TEAMS.md'" >/dev/null 2>&1; then + if _wait_workspace_team_context "${container}" "${workspace}/TEAMS.md" "${role}" 120; then log_pass "${container} TEAMS.md contains ${role} team context" + if docker exec "${container}" test ! -e "${qwenpaw_dir}/teamharness/team-context.json" >/dev/null 2>&1; then + log_pass "${container} TEAMS.md contains runtime roster facts without intermediate cache" + else + log_fail "${container} TEAMS.md created intermediate team context cache" + fi else log_fail "${container} TEAMS.md missing ${role} team context" - fi - - if docker exec "${container}" sh -c "grep -Fq '' '${workspace}/TEAMS.md' && grep -Fq 'runtimeName: ${TEST_LEADER}' '${workspace}/TEAMS.md' && grep -Fq 'runtimeName: ${TEST_WORKER}' '${workspace}/TEAMS.md' && grep -Fq 'matrixUserId: ${WORKER_MXID}' '${workspace}/TEAMS.md' && test ! -e '${qwenpaw_dir}/teamharness/team-context.json'" >/dev/null 2>&1; then - log_pass "${container} TEAMS.md contains runtime roster facts without intermediate cache" - else log_fail "${container} TEAMS.md missing runtime roster facts or created intermediate cache" fi @@ -1065,6 +1094,7 @@ model = os.environ["TEST_MODEL"] mcp_name = os.environ["TEST_MCP_NAME"] mcp_url = os.environ["TEST_MCP_URL"] mcp_transport = os.environ["TEST_MCP_TRANSPORT"] +api_mcp_transport = "streamable_http" if mcp_transport in {"http", "streamable_http"} else mcp_transport package_mcp_name = os.environ["TEST_PACKAGE_MCP_NAME"] package_mcp_url = os.environ["TEST_PACKAGE_MCP_URL"] @@ -1083,7 +1113,7 @@ if legacy_path.exists(): mcp = {item["key"]: item for item in get("/api/mcp")} server = mcp.get(mcp_name) or {} -if server.get("url") != mcp_url or server.get("transport") != mcp_transport: +if server.get("url") != mcp_url or server.get("transport") != api_mcp_transport: problems.append("mcp:api") authorization = (server.get("headers") or {}).get("Authorization", "") if not authorization or "*" not in authorization: From fd8a8bd701f2c0a5e5f1a515b51496a6e177aa2d Mon Sep 17 00:00:00 2001 From: yuhuafeng Date: Mon, 27 Jul 2026 21:03:28 +0800 Subject: [PATCH 26/37] fix(copaw): restore worker workspace projection --- copaw/src/copaw_worker/bridge.py | 182 +++++++++++++++++++++++++++++- copaw/src/copaw_worker/worker.py | 59 +++++----- tests/test-21-team-project-dag.sh | 17 ++- 3 files changed, 225 insertions(+), 33 deletions(-) diff --git a/copaw/src/copaw_worker/bridge.py b/copaw/src/copaw_worker/bridge.py index b5765d31a..9e0751f12 100644 --- a/copaw/src/copaw_worker/bridge.py +++ b/copaw/src/copaw_worker/bridge.py @@ -14,7 +14,185 @@ import shutil from importlib import resources from pathlib import Path -from typing import Any +from typing import Any, Callable + + +def bridge_standard_to_runtime( + standard_dir: Path, + runtime_dir: Path, + controller_config: dict[str, Any], + *, + skill_names: list[str] | None = None, + profile: str = "worker", +) -> None: + """Project controller-owned standard files into CoPaw's workspace.""" + sync_outer_prompt_files_to_inner(standard_dir, runtime_dir) + bridge_controller_to_copaw( + controller_config, + runtime_dir, + profile=profile, + ) + sync_mcporter_config_to_runtime(standard_dir, runtime_dir) + if skill_names is not None: + sync_skills_to_runtime(standard_dir, runtime_dir, skill_names) + + +def refresh_standard_to_runtime( + standard_dir: Path, + runtime_dir: Path, + controller_config: dict[str, Any], + *, + get_soul: Callable[[], str | None], + get_agents_md: Callable[[], str | None], + skill_names: list[str] | None = None, + profile: str = "worker", +) -> None: + """Refresh CoPaw's workspace while retaining legacy prompt fallbacks.""" + sync_rebridged_prompt_files_to_inner( + standard_dir, + runtime_dir, + get_soul=get_soul, + get_agents_md=get_agents_md, + ) + bridge_controller_to_copaw( + controller_config, + runtime_dir, + profile=profile, + ) + sync_mcporter_config_to_runtime(standard_dir, runtime_dir) + if skill_names is not None: + sync_skills_to_runtime(standard_dir, runtime_dir, skill_names) + + +def sync_outer_prompt_files_to_inner( + standard_dir: Path, + runtime_dir: Path, +) -> None: + """Copy standard prompt files into CoPaw's default workspace.""" + workspace_dir = runtime_dir / "workspaces" / "default" + workspace_dir.mkdir(parents=True, exist_ok=True) + + for name in ("SOUL.md", "AGENTS.md"): + src = standard_dir / name + if src.exists(): + shutil.copy2(src, workspace_dir / name) + + heartbeat_dst = workspace_dir / "HEARTBEAT.md" + heartbeat_src = standard_dir / "HEARTBEAT.md" + if not heartbeat_dst.exists() and heartbeat_src.exists(): + shutil.copy2(heartbeat_src, heartbeat_dst) + + +def sync_rebridged_prompt_files_to_inner( + standard_dir: Path, + runtime_dir: Path, + *, + get_soul: Callable[[], str | None], + get_agents_md: Callable[[], str | None], +) -> None: + """Refresh prompt files, using legacy storage readers as fallback.""" + workspace_dir = runtime_dir / "workspaces" / "default" + workspace_dir.mkdir(parents=True, exist_ok=True) + fallbacks = { + "SOUL.md": get_soul, + "AGENTS.md": get_agents_md, + } + for name, fallback in fallbacks.items(): + src = standard_dir / name + content = src.read_text() if src.exists() else fallback() + if content: + (workspace_dir / name).write_text(content) + + +def sync_mcporter_config_to_runtime( + standard_dir: Path, + runtime_dir: Path, +) -> Path | None: + """Copy mcporter config into CoPaw's default workspace.""" + candidates = ( + standard_dir / "config" / "mcporter.json", + standard_dir / "mcporter-servers.json", + ) + src = next((candidate for candidate in candidates if candidate.exists()), None) + if src is None: + return None + dst = runtime_dir / "workspaces" / "default" / "config" / "mcporter.json" + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + return dst + + +def sync_skills_to_runtime( + standard_dir: Path, + runtime_dir: Path, + skill_names: list[str], +) -> list[str]: + """Expose standard-space skills in CoPaw's default workspace.""" + standard_skills_dir = standard_dir / "skills" + standard_skills_dir.mkdir(parents=True, exist_ok=True) + wanted = set(skill_names) + + for script in standard_skills_dir.rglob("*.sh"): + script.chmod(script.stat().st_mode | 0o111) + for child in list(standard_skills_dir.iterdir()): + if child.is_dir() and child.name not in wanted: + shutil.rmtree(child) + + workspace_dir = runtime_dir / "workspaces" / "default" + workspace_dir.mkdir(parents=True, exist_ok=True) + workspace_skills = workspace_dir / "skills" + expected_target = standard_skills_dir.resolve() + if workspace_skills.is_symlink(): + if workspace_skills.resolve() != expected_target: + workspace_skills.unlink() + elif workspace_skills.exists(): + if workspace_skills.is_dir(): + shutil.rmtree(workspace_skills) + else: + workspace_skills.unlink() + if not workspace_skills.exists(): + target = os.path.relpath(standard_skills_dir, workspace_dir) + workspace_skills.symlink_to(target, target_is_directory=True) + + installed = [ + name + for name in skill_names + if (standard_skills_dir / name / "SKILL.md").exists() + ] + _enable_workspace_skills(runtime_dir, installed) + return installed + + +def _enable_workspace_skills(runtime_dir: Path, skill_names: list[str]) -> None: + if not skill_names: + return + workspace_dir = runtime_dir / "workspaces" / "default" + manifest_path = workspace_dir / "skill.json" + manifest: dict[str, Any] = { + "schema_version": "workspace-skill-manifest.v1", + "version": 1, + "skills": {}, + } + if manifest_path.exists(): + try: + loaded = json.loads(manifest_path.read_text()) + if isinstance(loaded, dict): + manifest.update(loaded) + except json.JSONDecodeError: + pass + if not isinstance(manifest.get("skills"), dict): + manifest["skills"] = {} + for name in sorted(set(skill_names)): + current = manifest["skills"].get(name) + if not isinstance(current, dict): + current = {"source": "customized"} + manifest["skills"][name] = current + current["enabled"] = True + if not current.get("channels"): + current["channels"] = ["all"] + manifest_path.write_text( + json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", + ) def _port_remap(url: str, is_container: bool) -> str: @@ -48,7 +226,7 @@ def _patch_copaw_paths(working_dir: Path) -> None: import copaw.constant as _const _const.WORKING_DIR = working_dir _const.SECRET_DIR = secret_dir - _const.ACTIVE_SKILLS_DIR = working_dir / "active_skills" + _const.ACTIVE_SKILLS_DIR = working_dir / "workspaces" / "default" / "skills" _const.CUSTOMIZED_SKILLS_DIR = working_dir / "customized_skills" _const.MEMORY_DIR = working_dir / "memory" _const.CUSTOM_CHANNELS_DIR = working_dir / "custom_channels" diff --git a/copaw/src/copaw_worker/worker.py b/copaw/src/copaw_worker/worker.py index ebf3e648d..edce07568 100644 --- a/copaw/src/copaw_worker/worker.py +++ b/copaw/src/copaw_worker/worker.py @@ -23,7 +23,12 @@ from copaw_worker.config import WorkerConfig from copaw_worker.sync import FileSync, sync_loop, push_loop -from copaw_worker.bridge import bridge_controller_to_copaw +from copaw_worker.bridge import ( + bridge_standard_to_runtime, + refresh_standard_to_runtime, + sync_mcporter_config_to_runtime, + sync_skills_to_runtime, +) from copaw_worker.worker_api import WorkerAPIServer from copaw_worker.health import HealthState, check_matrix_service @@ -133,12 +138,6 @@ async def start(self) -> bool: self._copaw_working_dir = self.config.install_dir / self.worker_name / ".copaw" self._copaw_working_dir.mkdir(parents=True, exist_ok=True) - # Write SOUL.md / AGENTS.md into CoPaw working dir (read from local copies pulled by mirror_all) - for name in ("SOUL.md", "AGENTS.md"): - src = self.sync.local_dir / name - if src.exists(): - (self._copaw_working_dir / name).write_text(src.read_text()) - # 5. Bridge openclaw.json -> CoPaw config.json + providers.json # Infer gateway port from FS endpoint so bridge's _port_remap uses # the correct host port instead of the hardcoded default. @@ -150,22 +149,20 @@ async def start(self) -> bool: console.print("[yellow]Bridging configuration to CoPaw...[/yellow]") try: - bridge_controller_to_copaw(openclaw_cfg, self._copaw_working_dir) + bridge_standard_to_runtime( + self.sync.local_dir, + self._copaw_working_dir, + openclaw_cfg, + skill_names=self.sync.list_skills(), + ) except Exception as exc: console.print(f"[red]Config bridge failed: {exc}[/red]") return False - # 6. Copy mcporter config into CoPaw working dir so mcporter finds - # ./config/mcporter.json when running from COPAW_WORKING_DIR - self._copy_mcporter_config() - - # 7. Install MatrixChannel into CoPaw's custom_channels dir + # 6. Install MatrixChannel into CoPaw's custom_channels dir self._install_matrix_channel() - # 8. Sync skills from MinIO into CoPaw's active_skills dir - self._sync_skills() - - # 9. Start background MinIO sync + # 7. Start background MinIO sync asyncio.create_task( sync_loop( self.sync, @@ -668,11 +665,18 @@ async def _on_files_pulled(self, pulled_files: list[str]) -> None: SOUL.md, AGENTS.md are Worker-managed and not pulled; use local copies.""" # Re-sync skills if any skill file changed if any(f.startswith("skills/") for f in pulled_files): - self._sync_skills() + sync_skills_to_runtime( + self.sync.local_dir, + self._copaw_working_dir, + self.sync.list_skills(), + ) # Copy mcporter config into CoPaw working dir when it changes if "config/mcporter.json" in pulled_files: - self._copy_mcporter_config() + sync_mcporter_config_to_runtime( + self.sync.local_dir, + self._copaw_working_dir, + ) needs_rebridge = "openclaw.json" in pulled_files if not needs_rebridge: @@ -681,16 +685,13 @@ async def _on_files_pulled(self, pulled_files: list[str]) -> None: console.print("[yellow]Config changed, re-bridging...[/yellow]") try: openclaw_cfg = self.sync.get_config() - # Use local Worker-managed files; fallback to MinIO for initial bootstrap - soul = (self.sync.local_dir / "SOUL.md").read_text() if (self.sync.local_dir / "SOUL.md").exists() else self.sync.get_soul() - agents = (self.sync.local_dir / "AGENTS.md").read_text() if (self.sync.local_dir / "AGENTS.md").exists() else self.sync.get_agents_md() - - if soul: - (self._copaw_working_dir / "SOUL.md").write_text(soul) - if agents: - (self._copaw_working_dir / "AGENTS.md").write_text(agents) - - bridge_controller_to_copaw(openclaw_cfg, self._copaw_working_dir) + refresh_standard_to_runtime( + self.sync.local_dir, + self._copaw_working_dir, + openclaw_cfg, + get_soul=self.sync.get_soul, + get_agents_md=self.sync.get_agents_md, + ) console.print("[green]Config re-bridged.[/green]") except Exception as exc: console.print(f"[red]Re-bridge failed: {exc}[/red]") diff --git a/tests/test-21-team-project-dag.sh b/tests/test-21-team-project-dag.sh index b1e419376..e86ffaf9a 100755 --- a/tests/test-21-team-project-dag.sh +++ b/tests/test-21-team-project-dag.sh @@ -241,6 +241,18 @@ COMMUNICATION_SKILL=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_LE COORDINATION_SKILL=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_LEADER}/skills/team-coordination/SKILL.md" 2>/dev/null) LEADER_AGENTS=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_LEADER}/AGENTS.md" 2>/dev/null) +# Worker and Team reconciliation are independent. When a standalone Worker is +# attached to a Team immediately after creation, its final standalone asset +# push can briefly race the Team Leader overlay. Wait for the role-specific +# desired state to converge before asserting its exact content. +for i in $(seq 1 12); do + if echo "${TASK_SKILL}" | grep -Fq "Task state is tool-owned"; then + break + fi + sleep 5 + TASK_SKILL=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_LEADER}/skills/task-management/SKILL.md" 2>/dev/null) +done + assert_contains "${PROJECT_SKILL}" "projectflow" "project-management documents projectflow" assert_contains "${PROJECT_SKILL}" "Project state is tool-owned" "project-management forbids manual project state mutation" assert_contains "${PROJECT_SKILL}" "ready_nodes" "project-management documents DAG ready nodes" @@ -356,10 +368,11 @@ log_info "Task sent to Leader via Leader DM. Monitoring rooms..." # This test validates the routing boundary, not full project completion. If the # Leader responds in Leader DM while no assignment appears in Team Room, the # route is wrong and extra waiting only slows CI. If there is no response at -# all, keep a short 120s ceiling for startup jitter. +# all, allow the real model enough time to load Team Leader prompts and make +# its first project/task tool calls. DM-only replies still fail fast below. TEAM_ROOM_ENC=$(echo "${TEAM_ROOM}" | sed 's/!/%21/g') LEADER_DM_ENC=$(echo "${LEADER_DM}" | sed 's/!/%21/g') -MAX_COORDINATION_POLLS="${MAX_COORDINATION_POLLS:-4}" +MAX_COORDINATION_POLLS="${MAX_COORDINATION_POLLS:-10}" MAX_DM_ONLY_POLLS="${MAX_DM_ONLY_POLLS:-1}" LEADER_RESPONDED=false From 66b87bd9ca8c042828f4e8e7c573f5b40d5d1d84 Mon Sep 17 00:00:00 2001 From: yuhuafeng Date: Mon, 27 Jul 2026 21:41:55 +0800 Subject: [PATCH 27/37] fix(controller): preserve team-owned worker config --- .../internal/controller/worker_controller.go | 21 ++++++++++---- .../controller/worker_controller_test.go | 28 +++++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/agentteams-controller/internal/controller/worker_controller.go b/agentteams-controller/internal/controller/worker_controller.go index 184d4ff7c..3f02fe350 100644 --- a/agentteams-controller/internal/controller/worker_controller.go +++ b/agentteams-controller/internal/controller/worker_controller.go @@ -202,6 +202,7 @@ func (r *WorkerReconciler) reconcileNormal(ctx context.Context, w *v1beta1.Worke if err != nil { return reconcile.Result{}, err } + configOwnedByTeam := mctx.TeamName != "" if effectiveSpec.ModelProvider != "" && r.GatewayClient != nil { info, err := r.GatewayClient.ResolveModelProvider(ctx, effectiveSpec.ModelProvider) @@ -248,9 +249,13 @@ func (r *WorkerReconciler) reconcileNormal(ctx context.Context, w *v1beta1.Worke applyMemberStateToWorker(w, state) return reconcile.Result{}, err } - if err := ReconcileMemberConfig(ctx, deps, mctx, state); err != nil { - applyMemberStateToWorker(w, state) - return reconcile.Result{}, err + if !configOwnedByTeam { + if err := ReconcileMemberConfig(ctx, deps, mctx, state); err != nil { + applyMemberStateToWorker(w, state) + return reconcile.Result{}, err + } + } else { + logger.Info("worker runtime config owned by TeamReconciler, skipping standalone config reconcile", "worker", w.Name, "team", mctx.TeamName) } applyMemberStateToWorker(w, state) w.Status.SpecHash = mctx.AppliedSpecHash @@ -274,9 +279,13 @@ func (r *WorkerReconciler) reconcileNormal(ctx context.Context, w *v1beta1.Worke applyMemberStateToWorker(w, state) return reconcile.Result{}, err } - if err := ReconcileMemberConfig(ctx, deps, mctx, state); err != nil { - applyMemberStateToWorker(w, state) - return reconcile.Result{}, err + if !configOwnedByTeam { + if err := ReconcileMemberConfig(ctx, deps, mctx, state); err != nil { + applyMemberStateToWorker(w, state) + return reconcile.Result{}, err + } + } else { + logger.Info("worker runtime config owned by TeamReconciler, skipping standalone config reconcile", "worker", w.Name, "team", mctx.TeamName) } if res, err := ReconcileMemberContainer(ctx, deps, mctx, state); err != nil || res.RequeueAfter > 0 { applyMemberStateToWorker(w, state) diff --git a/agentteams-controller/internal/controller/worker_controller_test.go b/agentteams-controller/internal/controller/worker_controller_test.go index dba72ccf4..1a8b96b16 100644 --- a/agentteams-controller/internal/controller/worker_controller_test.go +++ b/agentteams-controller/internal/controller/worker_controller_test.go @@ -142,6 +142,34 @@ func TestWorkerTeamNameUsesPersistedMembershipAnnotation(t *testing.T) { } } +func TestWorkerReconcileDoesNotOverwriteTeamOwnedRuntimeConfig(t *testing.T) { + for _, runtimeName := range []string{"copaw", "qwenpaw"} { + t.Run(runtimeName, func(t *testing.T) { + worker := newWorker("leader", v1beta1.WorkerSpec{Runtime: runtimeName}) + team := &v1beta1.Team{ + ObjectMeta: metav1.ObjectMeta{Name: "team-cr", Namespace: "default"}, + Spec: v1beta1.TeamSpec{ + TeamName: "runtime-team", + WorkerMembers: []v1beta1.TeamWorkerRef{ + {Name: "leader", Role: "team_leader"}, + }, + }, + } + rig := newWorkerRig(t, worker, team) + + if _, _, err := rig.reconcile("leader"); err != nil { + t.Fatalf("reconcile referenced Worker: %v", err) + } + if got := len(rig.deployer.Calls.DeployWorkerConfig); got != 0 { + t.Fatalf("DeployWorkerConfig calls=%d, want 0 for Team-owned Worker", got) + } + if got := len(rig.deployer.Calls.DeployMemberRuntimeConfig); got != 0 { + t.Fatalf("DeployMemberRuntimeConfig calls=%d, want 0 for Team-owned Worker", got) + } + }) + } +} + type workerTestGateway struct { modelInfo *gateway.ModelProviderInfo modelErr error From 66f5c9693c987fbec35b31e6b83711020a52db66 Mon Sep 17 00:00:00 2001 From: yuhuafeng Date: Mon, 27 Jul 2026 22:23:00 +0800 Subject: [PATCH 28/37] fix(controller): scope team runtime config ownership --- .../internal/controller/worker_controller.go | 14 ++++- .../controller/worker_controller_test.go | 63 ++++++++++++------- 2 files changed, 51 insertions(+), 26 deletions(-) diff --git a/agentteams-controller/internal/controller/worker_controller.go b/agentteams-controller/internal/controller/worker_controller.go index 3f02fe350..2000f8d2e 100644 --- a/agentteams-controller/internal/controller/worker_controller.go +++ b/agentteams-controller/internal/controller/worker_controller.go @@ -202,7 +202,11 @@ func (r *WorkerReconciler) reconcileNormal(ctx context.Context, w *v1beta1.Worke if err != nil { return reconcile.Result{}, err } - configOwnedByTeam := mctx.TeamName != "" + teamRole, inTeam, err := r.teamRoleForWorker(ctx, w.Namespace, w.Name) + if err != nil { + return reconcile.Result{}, err + } + configOwnedByTeam := inTeam && backend.ResolveRuntime(effectiveSpec.Runtime, r.DefaultRuntime) == backend.RuntimeQwenPaw if effectiveSpec.ModelProvider != "" && r.GatewayClient != nil { info, err := r.GatewayClient.ResolveModelProvider(ctx, effectiveSpec.ModelProvider) @@ -211,6 +215,10 @@ func (r *WorkerReconciler) reconcileNormal(ctx context.Context, w *v1beta1.Worke } mctx.ModelProviderInfo = info } + configContext := mctx + if inTeam && teamRole == RoleTeamLeader { + configContext.Role = RoleTeamLeader + } if mctx.DeployMode == v1beta1.DeployModeEdge { // Edge UUID rotation: when the UUID label changes, delete the SA so any @@ -250,7 +258,7 @@ func (r *WorkerReconciler) reconcileNormal(ctx context.Context, w *v1beta1.Worke return reconcile.Result{}, err } if !configOwnedByTeam { - if err := ReconcileMemberConfig(ctx, deps, mctx, state); err != nil { + if err := ReconcileMemberConfig(ctx, deps, configContext, state); err != nil { applyMemberStateToWorker(w, state) return reconcile.Result{}, err } @@ -280,7 +288,7 @@ func (r *WorkerReconciler) reconcileNormal(ctx context.Context, w *v1beta1.Worke return reconcile.Result{}, err } if !configOwnedByTeam { - if err := ReconcileMemberConfig(ctx, deps, mctx, state); err != nil { + if err := ReconcileMemberConfig(ctx, deps, configContext, state); err != nil { applyMemberStateToWorker(w, state) return reconcile.Result{}, err } diff --git a/agentteams-controller/internal/controller/worker_controller_test.go b/agentteams-controller/internal/controller/worker_controller_test.go index 1a8b96b16..44f28baab 100644 --- a/agentteams-controller/internal/controller/worker_controller_test.go +++ b/agentteams-controller/internal/controller/worker_controller_test.go @@ -143,30 +143,47 @@ func TestWorkerTeamNameUsesPersistedMembershipAnnotation(t *testing.T) { } func TestWorkerReconcileDoesNotOverwriteTeamOwnedRuntimeConfig(t *testing.T) { - for _, runtimeName := range []string{"copaw", "qwenpaw"} { - t.Run(runtimeName, func(t *testing.T) { - worker := newWorker("leader", v1beta1.WorkerSpec{Runtime: runtimeName}) - team := &v1beta1.Team{ - ObjectMeta: metav1.ObjectMeta{Name: "team-cr", Namespace: "default"}, - Spec: v1beta1.TeamSpec{ - TeamName: "runtime-team", - WorkerMembers: []v1beta1.TeamWorkerRef{ - {Name: "leader", Role: "team_leader"}, - }, - }, - } - rig := newWorkerRig(t, worker, team) + worker := newWorker("leader", v1beta1.WorkerSpec{Runtime: "qwenpaw"}) + team := &v1beta1.Team{ + ObjectMeta: metav1.ObjectMeta{Name: "team-cr", Namespace: "default"}, + Spec: v1beta1.TeamSpec{ + TeamName: "runtime-team", + WorkerMembers: []v1beta1.TeamWorkerRef{ + {Name: "leader", Role: "team_leader"}, + }, + }, + } + rig := newWorkerRig(t, worker, team) - if _, _, err := rig.reconcile("leader"); err != nil { - t.Fatalf("reconcile referenced Worker: %v", err) - } - if got := len(rig.deployer.Calls.DeployWorkerConfig); got != 0 { - t.Fatalf("DeployWorkerConfig calls=%d, want 0 for Team-owned Worker", got) - } - if got := len(rig.deployer.Calls.DeployMemberRuntimeConfig); got != 0 { - t.Fatalf("DeployMemberRuntimeConfig calls=%d, want 0 for Team-owned Worker", got) - } - }) + if _, _, err := rig.reconcile("leader"); err != nil { + t.Fatalf("reconcile referenced Worker: %v", err) + } + if got := len(rig.deployer.Calls.DeployMemberRuntimeConfig); got != 0 { + t.Fatalf("DeployMemberRuntimeConfig calls=%d, want 0 for Team-owned QwenPaw Worker", got) + } +} + +func TestWorkerReconcileUsesTeamLeaderAssetsForFileConfiguredRuntime(t *testing.T) { + worker := newWorker("leader", v1beta1.WorkerSpec{Runtime: "copaw"}) + team := &v1beta1.Team{ + ObjectMeta: metav1.ObjectMeta{Name: "team-cr", Namespace: "default"}, + Spec: v1beta1.TeamSpec{ + TeamName: "runtime-team", + WorkerMembers: []v1beta1.TeamWorkerRef{ + {Name: "leader", Role: "team_leader"}, + }, + }, + } + rig := newWorkerRig(t, worker, team) + + if _, _, err := rig.reconcile("leader"); err != nil { + t.Fatalf("reconcile referenced Worker: %v", err) + } + if got := len(rig.deployer.Calls.DeployWorkerConfig); got != 1 { + t.Fatalf("DeployWorkerConfig calls=%d, want 1", got) + } + if got := rig.deployer.Calls.DeployWorkerConfig[0].Role; got != RoleTeamLeader.String() { + t.Fatalf("DeployWorkerConfig role=%q, want %q", got, RoleTeamLeader.String()) } } From eaab5a196a0ef2c0949526d3a773b552e2807cf2 Mon Sep 17 00:00:00 2001 From: yuhuafeng Date: Mon, 27 Jul 2026 23:10:12 +0800 Subject: [PATCH 29/37] fix(teamharness): keep submitted tasks monotonic --- plugins/teamharness/mcp/server.py | 9 +++++---- plugins/tests/teamharness/mcp/tools/test-taskflow.rb | 7 +++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/plugins/teamharness/mcp/server.py b/plugins/teamharness/mcp/server.py index ca29da258..7bdc72c17 100644 --- a/plugins/teamharness/mcp/server.py +++ b/plugins/teamharness/mcp/server.py @@ -3885,10 +3885,11 @@ def _taskflow(arguments: dict[str, Any]) -> dict[str, Any]: pulled = _pull_task(arguments, task_id) task = _load_task(arguments, task_id) _require_task_mutable(arguments, task, task_id, action) - task["status"] = "in_progress" - task["acknowledged_by_role"] = role - _write_task(arguments, task) - _update_project_task(arguments, task.get("project_id", ""), task_id, status="in_progress") + if task.get("status") != "submitted": + task["status"] = "in_progress" + task["acknowledged_by_role"] = role + _write_task(arguments, task) + _update_project_task(arguments, task.get("project_id", ""), task_id, status="in_progress") spec_path = _task_dir(arguments, task_id) / "spec.md" spec = spec_path.read_text(encoding="utf-8") if spec_path.exists() else "" return { diff --git a/plugins/tests/teamharness/mcp/tools/test-taskflow.rb b/plugins/tests/teamharness/mcp/tools/test-taskflow.rb index 3ab25d53a..65e4dabd0 100644 --- a/plugins/tests/teamharness/mcp/tools/test-taskflow.rb +++ b/plugins/tests/teamharness/mcp/tools/test-taskflow.rb @@ -343,6 +343,13 @@ def block_yaml_import(name, *args, **kwargs): raise AssertionError(f"submit_task did not sync result: {submitted!r}") if submitted["task"].get("assigned_at") != assigned_at: raise AssertionError(f"submit_task should preserve assigned_at: {submitted!r}") + reacked = payload("taskflow", { + "role": "worker", + "action": "ack_task", + "payload": {"taskId": task_id}, + }) + if not reacked.get("ok") or reacked["task"].get("status") != "submitted": + raise AssertionError(f"ack_task should not regress a submitted task: {reacked!r}") submitted_result_text = detailed_result_path.read_text(encoding="utf-8") expected_result_text = ( "# Detailed Result Body\\n\\n" From 1dbf4b8e1b7128948e17900bc791dfe949f99385 Mon Sep 17 00:00:00 2001 From: yuhuafeng Date: Mon, 27 Jul 2026 23:10:21 +0800 Subject: [PATCH 30/37] fix(copaw): route team assignments through runtime facts --- .../internal/controller/team_controller.go | 2 +- .../controller/team_controller_test.go | 8 +++- .../src/copaw_worker/hooks/message_filter.py | 42 +++++++++++++++++++ copaw/src/copaw_worker/hooks/tools/message.py | 2 + copaw/tests/test_message_tool.py | 29 +++++++++++++ 5 files changed, 80 insertions(+), 3 deletions(-) diff --git a/agentteams-controller/internal/controller/team_controller.go b/agentteams-controller/internal/controller/team_controller.go index 029981549..b03f71b38 100644 --- a/agentteams-controller/internal/controller/team_controller.go +++ b/agentteams-controller/internal/controller/team_controller.go @@ -641,7 +641,7 @@ func (r *TeamReconciler) deployTeamRuntimeConfigs( if member.worker.Spec.DeployMode != nil { deployMode = *member.worker.Spec.DeployMode } - if runtime != backend.RuntimeQwenPaw && deployMode != v1beta1.DeployModeEdge { + if runtime != backend.RuntimeQwenPaw && runtime != backend.RuntimeCopaw && deployMode != v1beta1.DeployModeEdge { continue } role := RoleTeamWorker diff --git a/agentteams-controller/internal/controller/team_controller_test.go b/agentteams-controller/internal/controller/team_controller_test.go index 55f92ed77..c1ba3e898 100644 --- a/agentteams-controller/internal/controller/team_controller_test.go +++ b/agentteams-controller/internal/controller/team_controller_test.go @@ -295,7 +295,7 @@ func TestReconcileTeamTeamReferences_HappyPath(t *testing.T) { leaderWorker := &v1beta1.Worker{ ObjectMeta: metav1.ObjectMeta{Name: "lead", Namespace: "default"}, - Spec: v1beta1.WorkerSpec{Model: "qwen"}, + Spec: v1beta1.WorkerSpec{Runtime: "copaw", Model: "qwen"}, Status: v1beta1.WorkerStatus{ SpecHash: "leader-hash", Phase: "Running", @@ -308,7 +308,7 @@ func TestReconcileTeamTeamReferences_HappyPath(t *testing.T) { } worker1 := &v1beta1.Worker{ ObjectMeta: metav1.ObjectMeta{Name: "dev", Namespace: "default"}, - Spec: v1beta1.WorkerSpec{Model: "qwen"}, + Spec: v1beta1.WorkerSpec{Runtime: "copaw", Model: "qwen"}, Status: v1beta1.WorkerStatus{ SpecHash: "dev-hash", Phase: "Running", @@ -422,6 +422,10 @@ func TestReconcileTeamTeamReferences_HappyPath(t *testing.T) { if workerCoord.TeamLeaderName != "lead" { t.Errorf("workerCoord TeamLeaderName=%q, want lead", workerCoord.TeamLeaderName) } + leaderRuntime, ok := runtimeConfigCallFor(deployer.Calls.DeployMemberRuntimeConfig, "lead") + if !ok || leaderRuntime.Role != "team_leader" || leaderRuntime.TeamRoomID == "" || leaderRuntime.LeaderDMRoomID == "" { + t.Fatalf("CoPaw leader runtime config missing team routing facts: %#v", leaderRuntime) + } } func TestReconcileTeamTeamReferences_QwenPawProjectsRuntimeRoster(t *testing.T) { diff --git a/copaw/src/copaw_worker/hooks/message_filter.py b/copaw/src/copaw_worker/hooks/message_filter.py index 692d7e624..ca6823c3f 100644 --- a/copaw/src/copaw_worker/hooks/message_filter.py +++ b/copaw/src/copaw_worker/hooks/message_filter.py @@ -73,6 +73,48 @@ def extract_matrix_mentions(text: str) -> list[str]: return list(dict.fromkeys(_MATRIX_USER_ID_RE.findall(text or ""))) +def canonicalize_team_worker_mentions(text: str) -> str: + """Expand unambiguous Team Worker aliases to their full Matrix IDs.""" + if _runtime_config_field("member", "role") != "team_leader": + return text + team_name = _runtime_config_field("team", "name") + if not team_name or not _TEAM_LEADER_WORKER_ASSIGNMENT_RE.search(text or ""): + return text + + agents_path = _runtime_root() / "AGENTS.md" + try: + lines = agents_path.read_text(encoding="utf-8").splitlines() + except OSError: + return text + + aliases: dict[str, str] = {} + in_workers = False + for line in lines: + if line.strip() == "- **Team Workers**:": + in_workers = True + continue + if not in_workers: + continue + if not line.startswith(" - "): + break + match = _MATRIX_USER_ID_RE.search(line) + if not match: + continue + matrix_id = match.group(0) + localpart = matrix_id.split(":", 1)[0].removeprefix("@") + aliases[localpart] = matrix_id + prefix = f"{team_name}-" + if localpart.startswith(prefix): + aliases[localpart[len(prefix) :]] = matrix_id + + def replace(match: re.Match[str]) -> str: + matrix_id = match.group(0) + localpart = matrix_id.split(":", 1)[0].removeprefix("@") + return aliases.get(localpart, matrix_id) + + return _MATRIX_USER_ID_RE.sub(replace, text or "") + + def resolve_team_leader_assignment_room(text: str, room_id: str) -> str: """Route Team Leader worker assignments from Leader DM to Team Room.""" if _runtime_config_field("member", "role") != "team_leader": diff --git a/copaw/src/copaw_worker/hooks/tools/message.py b/copaw/src/copaw_worker/hooks/tools/message.py index 109cb838f..0af90a3d0 100644 --- a/copaw/src/copaw_worker/hooks/tools/message.py +++ b/copaw/src/copaw_worker/hooks/tools/message.py @@ -16,6 +16,7 @@ from agentscope.message import Msg, TextBlock from agentscope.tool import ToolResponse from copaw_worker.hooks.message_filter import ( + canonicalize_team_worker_mentions, extract_matrix_mentions, filter_outgoing_matrix_message, resolve_team_leader_assignment_room, @@ -417,6 +418,7 @@ async def message( parsed_target.identifier if parsed_target.kind == "room" else None ), ) + filtered_message = canonicalize_team_worker_mentions(filtered_message) mentions = extract_matrix_mentions(filtered_message) content = build_matrix_text_content(filtered_message, mentions) diff --git a/copaw/tests/test_message_tool.py b/copaw/tests/test_message_tool.py index 2e58ee3dd..c9ccc4278 100644 --- a/copaw/tests/test_message_tool.py +++ b/copaw/tests/test_message_tool.py @@ -24,6 +24,13 @@ def _write_team_leader_runtime(tmp_path): working_dir = tmp_path / "leader" / ".copaw" runtime_dir = tmp_path / "leader" / "runtime" runtime_dir.mkdir(parents=True) + (tmp_path / "leader" / "AGENTS.md").write_text( + "- **Team Workers**:\n" + " - @dag-team-1-dev:hs.local — Room: !dev:hs.local\n" + " - @dag-team-1-qa:hs.local — Room: !qa:hs.local\n" + "- Team coordination rules follow.\n", + encoding="utf-8", + ) (runtime_dir / "runtime.yaml").write_text( "kind: MemberRuntimeConfig\n" "member:\n" @@ -246,6 +253,28 @@ async def test_message_tool_routes_localpart_assignment_to_team_room( assert payload["roomId"] == "!team-room:hs.local" +@pytest.mark.asyncio +async def test_message_tool_expands_worker_alias_before_routing( + tmp_path, + monkeypatch, +): + monkeypatch.setenv("COPAW_WORKING_DIR", str(_write_team_leader_runtime(tmp_path))) + + response = await message( + action="send", + channel="matrix", + target="room:!leader-dm:hs.local", + message="@dev:hs.local New task assigned: design the API.", + dryRun=True, + ) + payload = _response_json(response) + + assert payload["ok"] is True + assert payload["roomId"] == "!team-room:hs.local" + assert payload["mentions"] == ["@dag-team-1-dev:hs.local"] + assert payload["content"]["body"].startswith("@dag-team-1-dev:hs.local ") + + def test_validate_matrix_message_policy_blocks_roster_preamble_with_mxids( tmp_path, monkeypatch, From 3790f4f94a67c934f996876b943ccc46aa28eeb3 Mon Sep 17 00:00:00 2001 From: yuhuafeng Date: Tue, 28 Jul 2026 00:32:31 +0800 Subject: [PATCH 31/37] fix(copaw): route team assignments to team room --- .../src/copaw_worker/hooks/message_filter.py | 3 +-- copaw/tests/test_message_tool.py | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/copaw/src/copaw_worker/hooks/message_filter.py b/copaw/src/copaw_worker/hooks/message_filter.py index ca6823c3f..0676d145e 100644 --- a/copaw/src/copaw_worker/hooks/message_filter.py +++ b/copaw/src/copaw_worker/hooks/message_filter.py @@ -121,9 +121,8 @@ def resolve_team_leader_assignment_room(text: str, room_id: str) -> str: return room_id team_room_id = _runtime_config_field("team", "teamRoomId") - leader_dm_room_id = _runtime_config_field("team", "leaderDmRoomId") team_name = _runtime_config_field("team", "name") - if not team_room_id or room_id != leader_dm_room_id: + if not team_room_id: return room_id if not _TEAM_LEADER_WORKER_ASSIGNMENT_RE.search(text or ""): return room_id diff --git a/copaw/tests/test_message_tool.py b/copaw/tests/test_message_tool.py index c9ccc4278..89838a63c 100644 --- a/copaw/tests/test_message_tool.py +++ b/copaw/tests/test_message_tool.py @@ -253,6 +253,26 @@ async def test_message_tool_routes_localpart_assignment_to_team_room( assert payload["roomId"] == "!team-room:hs.local" +@pytest.mark.asyncio +async def test_message_tool_routes_team_assignment_from_any_room_to_team_room( + tmp_path, + monkeypatch, +): + monkeypatch.setenv("COPAW_WORKING_DIR", str(_write_team_leader_runtime(tmp_path))) + + response = await message( + action="send", + channel="matrix", + target="room:!worker-room:hs.local", + message="@dag-team-1-dev:hs.local New task assigned: design the API.", + dryRun=True, + ) + payload = _response_json(response) + + assert payload["ok"] is True + assert payload["roomId"] == "!team-room:hs.local" + + @pytest.mark.asyncio async def test_message_tool_expands_worker_alias_before_routing( tmp_path, From fbdf76249254df52c65eae69a7d7ee0261135855 Mon Sep 17 00:00:00 2001 From: yuhuafeng Date: Tue, 28 Jul 2026 00:47:15 +0800 Subject: [PATCH 32/37] fix(controller): honor configured worker runtime in API --- agentteams-controller/internal/app/app.go | 2 ++ agentteams-controller/internal/server/http.go | 3 +++ .../internal/server/resource_handler.go | 7 +++--- .../internal/server/resource_handler_test.go | 24 +++++++++++++++++++ tests/test-14-git-collab.sh | 23 ++++++++++++++++++ 5 files changed, 55 insertions(+), 4 deletions(-) diff --git a/agentteams-controller/internal/app/app.go b/agentteams-controller/internal/app/app.go index 46772e9b6..fa4b342c2 100644 --- a/agentteams-controller/internal/app/app.go +++ b/agentteams-controller/internal/app/app.go @@ -643,6 +643,8 @@ func (a *App) initHTTPServer(_ context.Context) error { SocketPath: a.cfg.SocketPath, MatrixConfig: a.cfg.MatrixConfig(), Provisioner: a.provisioner, + + DefaultWorkerRuntime: a.cfg.DefaultWorkerRuntime, }) return nil } diff --git a/agentteams-controller/internal/server/http.go b/agentteams-controller/internal/server/http.go index 9b333e0ce..9aa4c1f5a 100644 --- a/agentteams-controller/internal/server/http.go +++ b/agentteams-controller/internal/server/http.go @@ -31,6 +31,8 @@ type ServerDeps struct { SocketPath string // Docker proxy (embedded only) MatrixConfig matrix.Config // for AppService rotation endpoint Provisioner *service.Provisioner // for Matrix token refresh + + DefaultWorkerRuntime string // install-time default for Worker create requests } // HTTPServer serves the unified controller REST API. @@ -63,6 +65,7 @@ func NewHTTPServer(addr string, deps ServerDeps) *HTTPServer { // --- Declarative resource CRUD --- rh := NewResourceHandler(deps.Client, deps.Namespace, deps.Backend, deps.ControllerName) + rh.defaultWorkerRuntime = deps.DefaultWorkerRuntime nameFn := authpkg.NameFromPath // Workers diff --git a/agentteams-controller/internal/server/resource_handler.go b/agentteams-controller/internal/server/resource_handler.go index 242184cb6..c42f4e802 100644 --- a/agentteams-controller/internal/server/resource_handler.go +++ b/agentteams-controller/internal/server/resource_handler.go @@ -29,6 +29,8 @@ type ResourceHandler struct { namespace string backend *backend.Registry + defaultWorkerRuntime string + // controllerName is stamped as agentteams.io/controller on every CR this // handler creates, overwriting any value supplied by the client. This // enforces that HTTP-created resources always belong to the serving @@ -82,10 +84,7 @@ func (h *ResourceHandler) CreateWorker(w http.ResponseWriter, r *http.Request) { if req.ContainerManaged != nil { containerManaged = *req.ContainerManaged } - runtime := req.Runtime - if runtime == "" { - runtime = backend.RuntimeOpenClaw - } + runtime := backend.ResolveRuntime(req.Runtime, h.defaultWorkerRuntime) worker := &v1beta1.Worker{ ObjectMeta: metav1.ObjectMeta{ diff --git a/agentteams-controller/internal/server/resource_handler_test.go b/agentteams-controller/internal/server/resource_handler_test.go index 371d95192..33ba900f7 100644 --- a/agentteams-controller/internal/server/resource_handler_test.go +++ b/agentteams-controller/internal/server/resource_handler_test.go @@ -12,6 +12,7 @@ import ( v1beta1 "github.com/agentscope-ai/AgentTeams/agentteams-controller/api/v1beta1" authpkg "github.com/agentscope-ai/AgentTeams/agentteams-controller/internal/auth" + "github.com/agentscope-ai/AgentTeams/agentteams-controller/internal/backend" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -738,6 +739,29 @@ func TestCreateWorkerDefaultsRuntime(t *testing.T) { } } +func TestCreateWorkerUsesConfiguredDefaultRuntime(t *testing.T) { + scheme := newServerTestScheme(t) + k8sClient := fake.NewClientBuilder().WithScheme(scheme).Build() + handler := NewResourceHandler(k8sClient, "default", nil, "") + handler.defaultWorkerRuntime = backend.RuntimeQwenPaw + + body := []byte(`{"name":"worker-cr"}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/workers", bytes.NewReader(body)) + rec := httptest.NewRecorder() + handler.CreateWorker(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("expected status %d, got %d: %s", http.StatusCreated, rec.Code, rec.Body.String()) + } + + var stored v1beta1.Worker + if err := k8sClient.Get(context.Background(), client.ObjectKey{Name: "worker-cr", Namespace: "default"}, &stored); err != nil { + t.Fatalf("get created worker: %v", err) + } + if got := stored.Spec.Runtime; got != backend.RuntimeQwenPaw { + t.Fatalf("worker.spec.runtime = %q, want %q", got, backend.RuntimeQwenPaw) + } +} + func TestCreateTeam_StampsControllerLabel(t *testing.T) { scheme := newServerTestScheme(t) leader := &v1beta1.Worker{ObjectMeta: metav1.ObjectMeta{Name: "l1", Namespace: "default"}} diff --git a/tests/test-14-git-collab.sh b/tests/test-14-git-collab.sh index 3b335dcea..2aef5f668 100755 --- a/tests/test-14-git-collab.sh +++ b/tests/test-14-git-collab.sh @@ -19,6 +19,8 @@ if ! require_llm_key; then exit 0 fi +TEST_WORKER_RUNTIME="${AGENTTEAMS_DEFAULT_WORKER_RUNTIME:-openclaw}" + ADMIN_LOGIN=$(matrix_login "${TEST_ADMIN_USER}" "${TEST_ADMIN_PASSWORD}") ADMIN_TOKEN=$(echo "${ADMIN_LOGIN}" | jq -r '.access_token') @@ -188,6 +190,27 @@ done assert_not_empty "${PROJECT_ROOM}" "Project room created by Manager" log_info "Project room: ${PROJECT_ROOM}" +case "${TEST_WORKER_RUNTIME}" in + openclaw) EXPECTED_WORKER_IMAGE="agentteams/worker-agent:" ;; + copaw) EXPECTED_WORKER_IMAGE="agentteams/copaw-worker:" ;; + hermes) EXPECTED_WORKER_IMAGE="agentteams/hermes-worker:" ;; + qwenpaw) EXPECTED_WORKER_IMAGE="agentteams/qwenpaw-worker:" ;; + *) EXPECTED_WORKER_IMAGE="" ;; +esac + +for WORKER_NAME in alice bob charlie; do + WORKER_JSON=$(exec_in_agent agt get workers "${WORKER_NAME}" -o json 2>/dev/null || echo "{}") + WORKER_RUNTIME=$(echo "${WORKER_JSON}" | jq -r '.runtime // empty') + assert_eq "${TEST_WORKER_RUNTIME}" "${WORKER_RUNTIME}" \ + "Worker ${WORKER_NAME} runtime matches test matrix (got: '${WORKER_RUNTIME}', want: '${TEST_WORKER_RUNTIME}')" + + if [ -n "${EXPECTED_WORKER_IMAGE}" ]; then + WORKER_IMAGE=$(docker inspect --format '{{.Config.Image}}' "agentteams-worker-${WORKER_NAME}" 2>/dev/null || true) + assert_contains "${WORKER_IMAGE}" "${EXPECTED_WORKER_IMAGE}" \ + "Worker ${WORKER_NAME} image matches runtime ${TEST_WORKER_RUNTIME} (got: '${WORKER_IMAGE}')" + fi +done + log_info "Waiting for Manager to post completion message in project room (timeout: 1800s)..." # First check if completion message was already posted COMPLETION_MSG=$(matrix_read_messages "${MANAGER_TOKEN}" "${PROJECT_ROOM}" 50 2>/dev/null | \ From 135b83f0401a5989d98a5acb76cd44aa7ad9854d Mon Sep 17 00:00:00 2001 From: yuhuafeng Date: Tue, 28 Jul 2026 01:37:31 +0800 Subject: [PATCH 33/37] test(integration): verify declared runtime and full workflow --- tests/test-14-git-collab.sh | 104 +++++++++++++++++++++++--- tests/test-17-worker-config-verify.sh | 22 ++++-- tests/test-21-team-project-dag.sh | 4 +- tests/test-24-skills-management.sh | 12 ++- 4 files changed, 122 insertions(+), 20 deletions(-) diff --git a/tests/test-14-git-collab.sh b/tests/test-14-git-collab.sh index 2aef5f668..85882405f 100755 --- a/tests/test-14-git-collab.sh +++ b/tests/test-14-git-collab.sh @@ -211,21 +211,103 @@ for WORKER_NAME in alice bob charlie; do fi done -log_info "Waiting for Manager to post completion message in project room (timeout: 1800s)..." -# First check if completion message was already posted -COMPLETION_MSG=$(matrix_read_messages "${MANAGER_TOKEN}" "${PROJECT_ROOM}" 50 2>/dev/null | \ - jq -r --arg u "@manager" '[.chunk[] | select(.sender | startswith($u)) | .content.body] | .[]' 2>/dev/null | \ - grep -iE "complete|done|finished|已完成|完成|all.*phase|phase.*4|PHASE4" | head -1 || true) +log_info "Waiting for Phase 4 verification branch (timeout: 1800s)..." +PHASE4_REF="" +DEADLINE=$(( $(date +%s) + 1800 )) +NEXT_NUDGE=$(( $(date +%s) + 120 )) +while [ "$(date +%s)" -lt "${DEADLINE}" ]; do + PHASE4_REF=$(docker exec "${TEST_CONTROLLER_CONTAINER}" \ + git --git-dir="${REPO_PATH}.git" rev-parse --verify "refs/heads/${TEST_BRANCH}" 2>/dev/null || true) + [ -n "${PHASE4_REF}" ] && break + + if [ "$(date +%s)" -ge "${NEXT_NUDGE}" ]; then + matrix_send_message "${ADMIN_TOKEN}" "${DM_ROOM}" \ + "Please continue the 4-phase workflow from the latest worker report in the project room. Do not stop until Phase 4 is complete." \ + 2>/dev/null || true + NEXT_NUDGE=$(( $(date +%s) + 120 )) + fi + sleep 10 +done +assert_not_empty "${PHASE4_REF}" "Phase 4 verification branch exists" + +log_section "Verify Four-Phase Git Results" + +FEATURE_CONTENT=$(docker exec "${TEST_CONTROLLER_CONTAINER}" \ + git --git-dir="${REPO_PATH}.git" show "refs/heads/${FEATURE_BRANCH}:doc/proposal.md" 2>/dev/null || true) +REVIEW_CONTENT=$(docker exec "${TEST_CONTROLLER_CONTAINER}" \ + git --git-dir="${REPO_PATH}.git" show "refs/heads/${REVIEW_BRANCH}:reviews/proposal-review.md" 2>/dev/null || true) +VERIFY_CONTENT=$(docker exec "${TEST_CONTROLLER_CONTAINER}" \ + git --git-dir="${REPO_PATH}.git" show "refs/heads/${TEST_BRANCH}:verify/checklist.md" 2>/dev/null || true) + +assert_contains "${FEATURE_CONTENT}" "## Summary" "Phase 3 added Summary to proposal" +assert_contains "${FEATURE_CONTENT}" "## Goals" "Feature branch retains proposal Goals" +assert_contains "${REVIEW_CONTENT}" "Please add a ## Summary section" "Phase 2 review requested Summary" +assert_contains "${VERIFY_CONTENT}" "Summary" "Phase 4 checklist verifies Summary" +assert_contains "${VERIFY_CONTENT}" "Goals" "Phase 4 checklist verifies Goals" +assert_contains_i "${VERIFY_CONTENT}" "review" "Phase 4 checklist verifies review was addressed" + +FEATURE_COMMITS=$(docker exec "${TEST_CONTROLLER_CONTAINER}" \ + git --git-dir="${REPO_PATH}.git" log --format=%s "refs/heads/${FEATURE_BRANCH}" 2>/dev/null || true) +REVIEW_COMMIT=$(docker exec "${TEST_CONTROLLER_CONTAINER}" \ + git --git-dir="${REPO_PATH}.git" log -1 --format=%s "refs/heads/${REVIEW_BRANCH}" 2>/dev/null || true) +VERIFY_COMMIT=$(docker exec "${TEST_CONTROLLER_CONTAINER}" \ + git --git-dir="${REPO_PATH}.git" log -1 --format=%s "refs/heads/${TEST_BRANCH}" 2>/dev/null || true) +assert_contains "${FEATURE_COMMITS}" "feat: add proposal" "Phase 1 commit message is exact" +assert_contains "${FEATURE_COMMITS}" "fix: add summary section per review" "Phase 3 commit message is exact" +assert_eq "review: request summary section" "${REVIEW_COMMIT}" "Phase 2 commit message is exact" +assert_eq "verify: proposal review checklist" "${VERIFY_COMMIT}" "Phase 4 commit message is exact" + +EVENT_DEADLINE=$(( $(date +%s) + 300 )) +while true; do + PROJECT_EVENTS=$(matrix_read_messages "${MANAGER_TOKEN}" "${PROJECT_ROOM}" 200 2>/dev/null || echo '{"chunk":[]}') + PHASE1_TS=$(echo "${PROJECT_EVENTS}" | jq -r --arg u "@alice:${TEST_MATRIX_DOMAIN}" \ + '[.chunk[] | select(.sender == $u and (.content.body // "" | contains("PHASE1_DONE"))) | .origin_server_ts] | min // 0') + PHASE2_TS=$(echo "${PROJECT_EVENTS}" | jq -r --arg u "@bob:${TEST_MATRIX_DOMAIN}" \ + '[.chunk[] | select(.sender == $u and (.content.body // "" | contains("REVISION_NEEDED"))) | .origin_server_ts] | min // 0') + PHASE3_TS=$(echo "${PROJECT_EVENTS}" | jq -r --arg u "@alice:${TEST_MATRIX_DOMAIN}" \ + '[.chunk[] | select(.sender == $u and (.content.body // "" | contains("PHASE3_DONE"))) | .origin_server_ts] | min // 0') + PHASE4_TS=$(echo "${PROJECT_EVENTS}" | jq -r --arg u "@charlie:${TEST_MATRIX_DOMAIN}" \ + '[.chunk[] | select(.sender == $u and (.content.body // "" | contains("PHASE4_DONE"))) | .origin_server_ts] | min // 0') + [ "${PHASE1_TS}" -gt 0 ] && [ "${PHASE2_TS}" -gt 0 ] \ + && [ "${PHASE3_TS}" -gt 0 ] && [ "${PHASE4_TS}" -gt 0 ] && break + [ "$(date +%s)" -ge "${EVENT_DEADLINE}" ] && break + sleep 10 +done +if [ "${PHASE1_TS}" -gt 0 ] && [ "${PHASE1_TS}" -lt "${PHASE2_TS}" ] \ + && [ "${PHASE2_TS}" -lt "${PHASE3_TS}" ] && [ "${PHASE3_TS}" -lt "${PHASE4_TS}" ]; then + log_pass "Worker phase reports occurred in required alice → bob → alice → charlie order" +else + log_fail "Missing or out-of-order phase reports (phase1=${PHASE1_TS}, phase2=${PHASE2_TS}, phase3=${PHASE3_TS}, phase4=${PHASE4_TS})" +fi + +log_info "Waiting for Manager's final project-room summary..." +COMPLETION_PATTERN="workflow.*(complete|completed|done|finished)|all.*4.*phase.*(complete|completed|done|finished)|4.phase.*(complete|completed|done|finished)|工作流.*完成|四.*阶段.*完成" +COMPLETION_MSG=$(echo "${PROJECT_EVENTS}" | \ + jq -r --arg u "@manager" '[.chunk[] | select(.sender | startswith($u)) | .content.body] | .[]' 2>/dev/null | \ + grep -iE "${COMPLETION_PATTERN}" | head -1 || true) if [ -z "${COMPLETION_MSG}" ]; then - COMPLETION_MSG=$(matrix_wait_for_message_containing "${MANAGER_TOKEN}" "${PROJECT_ROOM}" "@manager" \ - "complete\|done\|finished\|已完成\|完成\|all.*phase\|phase.*4\|PHASE4" 1800 \ - "${ADMIN_TOKEN}" "${DM_ROOM}" \ - "Please check the project room and continue coordinating the git collaboration workflow. If any phase is pending or a worker message was missed, please follow up." \ - 2>/dev/null || true) + SUMMARY_DEADLINE=$(( $(date +%s) + 300 )) + NEXT_SUMMARY_NUDGE=$(date +%s) + while [ "$(date +%s)" -lt "${SUMMARY_DEADLINE}" ]; do + if [ "$(date +%s)" -ge "${NEXT_SUMMARY_NUDGE}" ]; then + matrix_send_message "${ADMIN_TOKEN}" "${DM_ROOM}" \ + "Phase 4 is present in git. Please post the final workflow summary in the project room and @mention the human admin." \ + 2>/dev/null || true + NEXT_SUMMARY_NUDGE=$(( $(date +%s) + 60 )) + fi + sleep 15 + PROJECT_EVENTS=$(matrix_read_messages "${MANAGER_TOKEN}" "${PROJECT_ROOM}" 200 2>/dev/null || echo '{"chunk":[]}') + COMPLETION_MSG=$(echo "${PROJECT_EVENTS}" | \ + jq -r --arg u "@manager" '[.chunk[] | select(.sender | startswith($u)) | .content.body] | .[]' 2>/dev/null | \ + grep -iE "${COMPLETION_PATTERN}" | head -1 || true) + [ -n "${COMPLETION_MSG}" ] && break + done fi -assert_not_empty "${COMPLETION_MSG}" "Manager posted completion message in project room" +assert_not_empty "${COMPLETION_MSG}" "Manager posted final completion summary in project room" +assert_contains "${COMPLETION_MSG}" "@${TEST_ADMIN_USER}:${TEST_MATRIX_DOMAIN}" \ + "Manager final summary @mentions the human admin" log_pass "Workflow complete — Manager's message: $(echo "${COMPLETION_MSG}" | head -c 200)" log_section "Collect Metrics" diff --git a/tests/test-17-worker-config-verify.sh b/tests/test-17-worker-config-verify.sh index 94d160694..f2d87e275 100644 --- a/tests/test-17-worker-config-verify.sh +++ b/tests/test-17-worker-config-verify.sh @@ -17,6 +17,17 @@ test_setup "17-worker-config-verify" TEST_WORKER="test-cfg-$$" STORAGE_PREFIX="${STORAGE_PREFIX:-${TEST_STORAGE_PREFIX:-agentteams/agentteams-storage}}" +TEST_WORKER_RUNTIME="${AGENTTEAMS_DEFAULT_WORKER_RUNTIME:-openclaw}" + +if [ "${TEST_WORKER_RUNTIME}" = "copaw" ]; then + BUILTIN_CONTENT_SENTINEL="Task Execution Workflow" + BUILTIN_SKILLS=(communication file-sharing find-skills mcporter organization task-management) + FILE_SHARING_SKILL="file-sharing" +else + BUILTIN_CONTENT_SENTINEL="Every Session" + BUILTIN_SKILLS=(file-sync task-progress mcporter find-skills project-participation) + FILE_SHARING_SKILL="file-sync" +fi _cleanup() { log_info "Cleaning up: ${TEST_WORKER}" @@ -120,7 +131,8 @@ assert_contains "${AGENTS_CONTENT}" "agentteams-builtin-start" "AGENTS.md has bu assert_contains "${AGENTS_CONTENT}" "agentteams-builtin-end" "AGENTS.md has builtin-end marker" # Builtin content filled (not empty between markers) -assert_contains "${AGENTS_CONTENT}" "Every Session" "AGENTS.md builtin section has content (not empty)" +assert_contains "${AGENTS_CONTENT}" "${BUILTIN_CONTENT_SENTINEL}" \ + "AGENTS.md builtin section matches ${TEST_WORKER_RUNTIME} runtime" # Team-context coordination block present assert_contains "${AGENTS_CONTENT}" "agentteams-team-context-start" "AGENTS.md has team-context-start marker" @@ -146,7 +158,7 @@ fi log_section "Verify Skills in MinIO" # Builtin skills should be present -for skill in file-sync task-progress mcporter find-skills project-participation; do +for skill in "${BUILTIN_SKILLS[@]}"; do SKILL_EXISTS=$(exec_in_manager bash -c "mc ls '${STORAGE_PREFIX}/agents/${TEST_WORKER}/skills/${skill}/SKILL.md' >/dev/null 2>&1 && echo yes || echo no") if [ "${SKILL_EXISTS}" = "yes" ]; then log_pass "Builtin skill present: ${skill}" @@ -164,11 +176,11 @@ else fi # Verify skill content uses "coordinator" not "Manager" -FILESYNC_CONTENT=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_WORKER}/skills/file-sync/SKILL.md" 2>/dev/null || echo "") +FILESYNC_CONTENT=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_WORKER}/skills/${FILE_SHARING_SKILL}/SKILL.md" 2>/dev/null || echo "") if echo "${FILESYNC_CONTENT}" | grep -q "coordinator"; then - log_pass "file-sync SKILL.md uses 'coordinator'" + log_pass "${FILE_SHARING_SKILL} SKILL.md uses 'coordinator'" else - log_fail "file-sync SKILL.md does not use 'coordinator'" + log_fail "${FILE_SHARING_SKILL} SKILL.md does not use 'coordinator'" fi # ============================================================ diff --git a/tests/test-21-team-project-dag.sh b/tests/test-21-team-project-dag.sh index e86ffaf9a..26cb18e02 100755 --- a/tests/test-21-team-project-dag.sh +++ b/tests/test-21-team-project-dag.sh @@ -246,11 +246,13 @@ LEADER_AGENTS=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_LEADER}/ # push can briefly race the Team Leader overlay. Wait for the role-specific # desired state to converge before asserting its exact content. for i in $(seq 1 12); do - if echo "${TASK_SKILL}" | grep -Fq "Task state is tool-owned"; then + if echo "${TASK_SKILL}" | grep -Fq "Task state is tool-owned" \ + && echo "${LEADER_AGENTS}" | grep -Fq "Project/tool boundary"; then break fi sleep 5 TASK_SKILL=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_LEADER}/skills/task-management/SKILL.md" 2>/dev/null) + LEADER_AGENTS=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_LEADER}/AGENTS.md" 2>/dev/null) done assert_contains "${PROJECT_SKILL}" "projectflow" "project-management documents projectflow" diff --git a/tests/test-24-skills-management.sh b/tests/test-24-skills-management.sh index d104c3790..77ca39c2e 100755 --- a/tests/test-24-skills-management.sh +++ b/tests/test-24-skills-management.sh @@ -21,6 +21,12 @@ test_setup "24-skills-management" TEST_WORKER="test-skl-$$" STORAGE_PREFIX="${STORAGE_PREFIX:-${TEST_STORAGE_PREFIX:-agentteams/agentteams-storage}}" +TEST_WORKER_RUNTIME="${AGENTTEAMS_DEFAULT_WORKER_RUNTIME:-openclaw}" +if [ "${TEST_WORKER_RUNTIME}" = "copaw" ]; then + BASELINE_SKILL="file-sharing" +else + BASELINE_SKILL="file-sync" +fi _cleanup() { log_info "Cleaning up: ${TEST_WORKER}" @@ -84,10 +90,10 @@ else fi # Built-in baseline skill should be present in MinIO regardless of --skills -if minio_file_exists "agents/${TEST_WORKER}/skills/file-sync/SKILL.md"; then - log_pass "Built-in skill 'file-sync' present in MinIO" +if minio_file_exists "agents/${TEST_WORKER}/skills/${BASELINE_SKILL}/SKILL.md"; then + log_pass "Built-in skill '${BASELINE_SKILL}' present in MinIO for ${TEST_WORKER_RUNTIME} runtime" else - log_fail "Built-in skill 'file-sync' missing in MinIO" + log_fail "Built-in skill '${BASELINE_SKILL}' missing in MinIO for ${TEST_WORKER_RUNTIME} runtime" fi if minio_file_exists "agents/${TEST_WORKER}/skills/github-operations/SKILL.md"; then From d44de0c684d682110e5c8ac468ab4359e5f2f08e Mon Sep 17 00:00:00 2001 From: yuhuafeng Date: Tue, 28 Jul 2026 01:57:22 +0800 Subject: [PATCH 34/37] test(integration): verify qwenpaw runtime state --- tests/lib/test-helpers.sh | 51 ++++++++++- tests/test-17-worker-config-verify.sh | 106 ++++++++++++++-------- tests/test-18-team-config-verify.sh | 124 ++++++++++++++++---------- tests/test-19-human-and-team-admin.sh | 33 ++++--- tests/test-24-skills-management.sh | 14 ++- 5 files changed, 228 insertions(+), 100 deletions(-) diff --git a/tests/lib/test-helpers.sh b/tests/lib/test-helpers.sh index 771bb83a8..b369dd56c 100755 --- a/tests/lib/test-helpers.sh +++ b/tests/lib/test-helpers.sh @@ -402,18 +402,63 @@ wait_agent_file_contains() { return 1 } +# Read a runtime-owned prompt/config file from the location consumed by the +# selected Worker runtime. QwenPaw consumes its default workspace directly; +# other runtimes keep the historical top-level OSS layout. +read_worker_runtime_file() { + local agent_name="$1" + local rel_path="$2" + local runtime="${AGENTTEAMS_DEFAULT_WORKER_RUNTIME:-openclaw}" + local storage_prefix="${STORAGE_PREFIX:?STORAGE_PREFIX is required}" + if [ "${runtime}" = "qwenpaw" ]; then + docker exec "$(worker_container_name "${agent_name}")" cat \ + "/root/agentteams-fs/agents/${agent_name}/.qwenpaw/workspaces/default/${rel_path}" \ + 2>/dev/null || true + return + fi + exec_in_manager mc cat "${storage_prefix}/agents/${agent_name}/${rel_path}" 2>/dev/null || true +} + +# Read the effective Matrix allow-list. QwenPaw applies the Controller policy +# through the public ACL API; other runtimes persist it in openclaw.json. +read_worker_matrix_allowlist() { + local agent_name="$1" + local runtime="${AGENTTEAMS_DEFAULT_WORKER_RUNTIME:-openclaw}" + local storage_prefix="${STORAGE_PREFIX:?STORAGE_PREFIX is required}" + if [ "${runtime}" = "qwenpaw" ] && [ "${agent_name}" != "manager" ]; then + docker exec "$(worker_container_name "${agent_name}")" \ + curl -sf http://127.0.0.1:8088/api/access-control/agentteams_matrix \ + 2>/dev/null | jq -r '.whitelist | keys[]?' 2>/dev/null + return + fi + exec_in_manager mc cat "${storage_prefix}/agents/${agent_name}/openclaw.json" 2>/dev/null | \ + jq -r '.channels.matrix.groupAllowFrom[]?' 2>/dev/null +} + +read_qwenpaw_skills() { + local agent_name="$1" + docker exec "$(worker_container_name "${agent_name}")" \ + curl -sf http://127.0.0.1:8088/api/skills 2>/dev/null || printf '[]\n' +} + # wait_agent_matrix_allow_contains [timeout_seconds] -# Polls openclaw.json until channels.matrix allow-lists contain the expected ID. +# Polls the selected runtime's effective Matrix allow-list until it contains +# the expected ID. QwenPaw intentionally exposes one channel-scoped ACL for +# both group and DM messages. wait_agent_matrix_allow_contains() { local agent_name="$1" local jq_path="$2" local matrix_id="$3" local timeout="${4:-120}" local elapsed=0 - local storage_prefix="${STORAGE_PREFIX:?STORAGE_PREFIX is required}" local last="" while [ "${elapsed}" -lt "${timeout}" ]; do - last=$(exec_in_manager mc cat "${storage_prefix}/agents/${agent_name}/openclaw.json" 2>/dev/null | jq -r "${jq_path}[]?" 2>/dev/null) + if [ "${AGENTTEAMS_DEFAULT_WORKER_RUNTIME:-openclaw}" = "qwenpaw" ] && [ "${agent_name}" != "manager" ]; then + last=$(read_worker_matrix_allowlist "${agent_name}") + else + local storage_prefix="${STORAGE_PREFIX:?STORAGE_PREFIX is required}" + last=$(exec_in_manager mc cat "${storage_prefix}/agents/${agent_name}/openclaw.json" 2>/dev/null | jq -r "${jq_path}[]?" 2>/dev/null) + fi if echo "${last}" | grep -Fq "${matrix_id}"; then return 0 fi diff --git a/tests/test-17-worker-config-verify.sh b/tests/test-17-worker-config-verify.sh index f2d87e275..0aeb3057b 100644 --- a/tests/test-17-worker-config-verify.sh +++ b/tests/test-17-worker-config-verify.sh @@ -23,6 +23,10 @@ if [ "${TEST_WORKER_RUNTIME}" = "copaw" ]; then BUILTIN_CONTENT_SENTINEL="Task Execution Workflow" BUILTIN_SKILLS=(communication file-sharing find-skills mcporter organization task-management) FILE_SHARING_SKILL="file-sharing" +elif [ "${TEST_WORKER_RUNTIME}" = "qwenpaw" ]; then + BUILTIN_CONTENT_SENTINEL="Session files are runtime-private state" + BUILTIN_SKILLS=(communication file-sharing find-skills mcporter organization task-management) + FILE_SHARING_SKILL="file-sharing" else BUILTIN_CONTENT_SENTINEL="Every Session" BUILTIN_SKILLS=(file-sync task-progress mcporter find-skills project-participation) @@ -123,23 +127,26 @@ fi # ============================================================ log_section "Verify AGENTS.md" -AGENTS_CONTENT=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_WORKER}/AGENTS.md" 2>/dev/null || echo "") +AGENTS_CONTENT=$(read_worker_runtime_file "${TEST_WORKER}" "AGENTS.md") assert_not_empty "${AGENTS_CONTENT}" "AGENTS.md exists in MinIO" -# Builtin markers present -assert_contains "${AGENTS_CONTENT}" "agentteams-builtin-start" "AGENTS.md has builtin-start marker" -assert_contains "${AGENTS_CONTENT}" "agentteams-builtin-end" "AGENTS.md has builtin-end marker" +# QwenPaw loads TeamHarness prompts and skills through the public plugin API; +# its workspace AGENTS.md remains the user/package prompt. Other runtimes keep +# the legacy merged marker layout. +if [ "${TEST_WORKER_RUNTIME}" != "qwenpaw" ]; then + assert_contains "${AGENTS_CONTENT}" "agentteams-builtin-start" "AGENTS.md has builtin-start marker" + assert_contains "${AGENTS_CONTENT}" "agentteams-builtin-end" "AGENTS.md has builtin-end marker" +fi # Builtin content filled (not empty between markers) assert_contains "${AGENTS_CONTENT}" "${BUILTIN_CONTENT_SENTINEL}" \ "AGENTS.md builtin section matches ${TEST_WORKER_RUNTIME} runtime" -# Team-context coordination block present -assert_contains "${AGENTS_CONTENT}" "agentteams-team-context-start" "AGENTS.md has team-context-start marker" -assert_contains "${AGENTS_CONTENT}" "agentteams-team-context-end" "AGENTS.md has team-context-end marker" - -# Standalone worker: coordinator should be Manager -assert_contains "${AGENTS_CONTENT}" "@manager:" "Coordination block references Manager as coordinator" +if [ "${TEST_WORKER_RUNTIME}" != "qwenpaw" ]; then + assert_contains "${AGENTS_CONTENT}" "agentteams-team-context-start" "AGENTS.md has team-context-start marker" + assert_contains "${AGENTS_CONTENT}" "agentteams-team-context-end" "AGENTS.md has team-context-end marker" + assert_contains "${AGENTS_CONTENT}" "@manager:" "Coordination block references Manager as coordinator" +fi # User custom content preserved assert_contains "${AGENTS_CONTENT}" "My Custom Agent Instructions" "User-provided AGENTS.md content preserved" @@ -157,30 +164,44 @@ fi # ============================================================ log_section "Verify Skills in MinIO" -# Builtin skills should be present -for skill in "${BUILTIN_SKILLS[@]}"; do - SKILL_EXISTS=$(exec_in_manager bash -c "mc ls '${STORAGE_PREFIX}/agents/${TEST_WORKER}/skills/${skill}/SKILL.md' >/dev/null 2>&1 && echo yes || echo no") - if [ "${SKILL_EXISTS}" = "yes" ]; then - log_pass "Builtin skill present: ${skill}" - else - log_fail "Builtin skill missing: ${skill}" - fi -done +# Builtin skills should be present in the runtime that actually consumes them. +if [ "${TEST_WORKER_RUNTIME}" = "qwenpaw" ]; then + QWENPAW_SKILLS=$(read_qwenpaw_skills "${TEST_WORKER}") + for skill in "${BUILTIN_SKILLS[@]}"; do + if echo "${QWENPAW_SKILLS}" | jq -e --arg skill "${skill}" \ + '.[] | select(.name == $skill and .source == "plugin:teamharness")' >/dev/null 2>&1; then + log_pass "Builtin plugin skill present: ${skill}" + else + log_fail "Builtin plugin skill missing: ${skill}" + fi + done +else + for skill in "${BUILTIN_SKILLS[@]}"; do + SKILL_EXISTS=$(exec_in_manager bash -c "mc ls '${STORAGE_PREFIX}/agents/${TEST_WORKER}/skills/${skill}/SKILL.md' >/dev/null 2>&1 && echo yes || echo no") + if [ "${SKILL_EXISTS}" = "yes" ]; then + log_pass "Builtin skill present: ${skill}" + else + log_fail "Builtin skill missing: ${skill}" + fi + done +fi # Custom skill from ZIP should be present in skills/ (alongside builtins) -CUSTOM_SKILL=$(exec_in_manager bash -c "mc ls '${STORAGE_PREFIX}/agents/${TEST_WORKER}/skills/my-custom-skill/SKILL.md' >/dev/null 2>&1 && echo yes || echo no") -if [ "${CUSTOM_SKILL}" = "yes" ]; then +CUSTOM_SKILL_CONTENT=$(read_worker_runtime_file "${TEST_WORKER}" "skills/my-custom-skill/SKILL.md") +if echo "${CUSTOM_SKILL_CONTENT}" | grep -Fq "Custom skill content"; then log_pass "Custom skill from ZIP present: my-custom-skill" else log_fail "Custom skill from ZIP missing: my-custom-skill" fi # Verify skill content uses "coordinator" not "Manager" -FILESYNC_CONTENT=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_WORKER}/skills/${FILE_SHARING_SKILL}/SKILL.md" 2>/dev/null || echo "") -if echo "${FILESYNC_CONTENT}" | grep -q "coordinator"; then - log_pass "${FILE_SHARING_SKILL} SKILL.md uses 'coordinator'" -else - log_fail "${FILE_SHARING_SKILL} SKILL.md does not use 'coordinator'" +if [ "${TEST_WORKER_RUNTIME}" != "qwenpaw" ]; then + FILESYNC_CONTENT=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_WORKER}/skills/${FILE_SHARING_SKILL}/SKILL.md" 2>/dev/null || echo "") + if echo "${FILESYNC_CONTENT}" | grep -q "coordinator"; then + log_pass "${FILE_SHARING_SKILL} SKILL.md uses 'coordinator'" + else + log_fail "${FILE_SHARING_SKILL} SKILL.md does not use 'coordinator'" + fi fi # ============================================================ @@ -188,19 +209,28 @@ fi # ============================================================ log_section "Verify MinIO Artifacts" -SOUL_CONTENT=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_WORKER}/SOUL.md" 2>/dev/null || echo "") +SOUL_CONTENT=$(read_worker_runtime_file "${TEST_WORKER}" "SOUL.md") assert_contains "${SOUL_CONTENT}" "Config verification test worker" "SOUL.md has correct content from ZIP" -OPENCLAW_EXISTS=$(exec_in_manager bash -c "mc ls '${STORAGE_PREFIX}/agents/${TEST_WORKER}/openclaw.json' >/dev/null 2>&1 && echo yes || echo no") -if [ "${OPENCLAW_EXISTS}" = "yes" ]; then - log_pass "openclaw.json exists in MinIO" +if [ "${TEST_WORKER_RUNTIME}" = "qwenpaw" ]; then + RUNTIME_CONFIG=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_WORKER}/runtime/runtime.yaml" 2>/dev/null || true) + if echo "${RUNTIME_CONFIG}" | grep -Fq 'runtime: qwenpaw'; then + log_pass "runtime.yaml exists and declares qwenpaw" + else + log_fail "runtime.yaml missing qwenpaw runtime declaration" + fi else - log_fail "openclaw.json missing from MinIO" + OPENCLAW_EXISTS=$(exec_in_manager bash -c "mc ls '${STORAGE_PREFIX}/agents/${TEST_WORKER}/openclaw.json' >/dev/null 2>&1 && echo yes || echo no") + if [ "${OPENCLAW_EXISTS}" = "yes" ]; then + log_pass "openclaw.json exists in MinIO" + else + log_fail "openclaw.json missing from MinIO" + fi fi # Verify groupAllowFrom has Manager (standalone worker) -OPENCLAW_CONTENT=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_WORKER}/openclaw.json" 2>/dev/null || echo "") -if echo "${OPENCLAW_CONTENT}" | jq -r '.channels.matrix.groupAllowFrom[]' 2>/dev/null | grep -q "@manager:"; then +MATRIX_ALLOWLIST=$(read_worker_matrix_allowlist "${TEST_WORKER}") +if echo "${MATRIX_ALLOWLIST}" | grep -q "@manager:"; then log_pass "groupAllowFrom includes Manager" else log_fail "groupAllowFrom does not include Manager" @@ -302,9 +332,13 @@ else fi # Verify AGENTS.md still has all sections -AGENTS_AFTER=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_WORKER}/AGENTS.md" 2>/dev/null || echo "") -assert_contains "${AGENTS_AFTER}" "agentteams-builtin-start" "AGENTS.md still has builtin markers after update" -assert_contains "${AGENTS_AFTER}" "agentteams-team-context-start" "AGENTS.md still has team-context after update" +AGENTS_AFTER=$(read_worker_runtime_file "${TEST_WORKER}" "AGENTS.md") +if [ "${TEST_WORKER_RUNTIME}" != "qwenpaw" ]; then + assert_contains "${AGENTS_AFTER}" "agentteams-builtin-start" "AGENTS.md still has builtin markers after update" + assert_contains "${AGENTS_AFTER}" "agentteams-team-context-start" "AGENTS.md still has team-context after update" +else + assert_contains "${AGENTS_AFTER}" "Session files are runtime-private state" "QwenPaw runtime prompt policy remains after update" +fi assert_contains "${AGENTS_AFTER}" "My Custom Agent Instructions" "User content still preserved after update" # ============================================================ diff --git a/tests/test-18-team-config-verify.sh b/tests/test-18-team-config-verify.sh index 13a121329..f58d55b26 100644 --- a/tests/test-18-team-config-verify.sh +++ b/tests/test-18-team-config-verify.sh @@ -22,6 +22,7 @@ TEST_LEADER="${TEST_TEAM}-lead" TEST_W1="${TEST_TEAM}-dev" TEST_W2="${TEST_TEAM}-qa" STORAGE_PREFIX="${STORAGE_PREFIX:-${TEST_STORAGE_PREFIX:-agentteams/agentteams-storage}}" +TEST_WORKER_RUNTIME="${AGENTTEAMS_DEFAULT_WORKER_RUNTIME:-openclaw}" _cleanup() { log_info "Cleaning up team: ${TEST_TEAM}" @@ -224,45 +225,56 @@ assert_eq "worker" "${W2_ROLE}" "Worker 2 has role=worker" # ============================================================ log_section "Verify Leader AGENTS.md" -LEADER_AGENTS=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_LEADER}/AGENTS.md" 2>/dev/null || echo "") +if [ "${TEST_WORKER_RUNTIME}" = "qwenpaw" ]; then + LEADER_AGENTS=$(read_worker_runtime_file "${TEST_LEADER}" "TEAMS.md") +else + LEADER_AGENTS=$(read_worker_runtime_file "${TEST_LEADER}" "AGENTS.md") +fi assert_not_empty "${LEADER_AGENTS}" "Leader AGENTS.md exists in MinIO" -# Builtin markers -assert_contains "${LEADER_AGENTS}" "agentteams-builtin-start" "Leader AGENTS.md has builtin-start" -assert_contains "${LEADER_AGENTS}" "agentteams-builtin-end" "Leader AGENTS.md has builtin-end" - -# Team-context: upstream = Manager -assert_contains "${LEADER_AGENTS}" "agentteams-team-context-start" "Leader has team-context block" -assert_contains "${LEADER_AGENTS}" "@manager:" "Leader coordination: upstream is Manager" -assert_contains "${LEADER_AGENTS}" "Upstream" "Leader coordination: has Upstream label" -assert_contains "${LEADER_AGENTS}" "${TEST_TEAM}" "Leader coordination: references team name" +if [ "${TEST_WORKER_RUNTIME}" = "qwenpaw" ]; then + assert_contains "${LEADER_AGENTS}" "BEGIN AGENTTEAMS RUNTIME TEAM CONTEXT" "Leader has runtime team-context block" + assert_contains "${LEADER_AGENTS}" "member.role: team_leader" "Leader runtime context has team_leader role" + assert_contains "${LEADER_AGENTS}" "${TEST_TEAM}" "Leader runtime context references team name" +else + assert_contains "${LEADER_AGENTS}" "agentteams-builtin-start" "Leader AGENTS.md has builtin-start" + assert_contains "${LEADER_AGENTS}" "agentteams-builtin-end" "Leader AGENTS.md has builtin-end" + assert_contains "${LEADER_AGENTS}" "agentteams-team-context-start" "Leader has team-context block" + assert_contains "${LEADER_AGENTS}" "@manager:" "Leader coordination: upstream is Manager" + assert_contains "${LEADER_AGENTS}" "Upstream" "Leader coordination: has Upstream label" + assert_contains "${LEADER_AGENTS}" "${TEST_TEAM}" "Leader coordination: references team name" +fi # ============================================================ # Section 6: Verify Team Worker AGENTS.md # ============================================================ log_section "Verify Team Worker AGENTS.md" -W1_AGENTS=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_W1}/AGENTS.md" 2>/dev/null || echo "") +if [ "${TEST_WORKER_RUNTIME}" = "qwenpaw" ]; then + W1_AGENTS=$(read_worker_runtime_file "${TEST_W1}" "TEAMS.md") +else + W1_AGENTS=$(read_worker_runtime_file "${TEST_W1}" "AGENTS.md") +fi assert_not_empty "${W1_AGENTS}" "Worker 1 AGENTS.md exists in MinIO" -# Builtin markers -assert_contains "${W1_AGENTS}" "agentteams-builtin-start" "Worker 1 AGENTS.md has builtin-start" -assert_contains "${W1_AGENTS}" "agentteams-builtin-end" "Worker 1 AGENTS.md has builtin-end" - -# Team-context: coordinator = Leader (NOT Manager) -assert_contains "${W1_AGENTS}" "agentteams-team-context-start" "Worker 1 has team-context block" -assert_contains "${W1_AGENTS}" "@${TEST_LEADER}:" "Worker 1 coordinator is Team Leader" - -# Should NOT reference Manager as coordinator -W1_CTX=$(echo "${W1_AGENTS}" | sed -n '/agentteams-team-context-start/,/agentteams-team-context-end/p') -if echo "${W1_CTX}" | grep -q "@manager:"; then - log_fail "Worker 1 team-context references Manager (should only reference Leader)" +if [ "${TEST_WORKER_RUNTIME}" = "qwenpaw" ]; then + assert_contains "${W1_AGENTS}" "BEGIN AGENTTEAMS RUNTIME TEAM CONTEXT" "Worker 1 has runtime team-context block" + assert_contains "${W1_AGENTS}" "team.leaderRuntimeName: ${TEST_LEADER}" "Worker 1 runtime context names Team Leader" + assert_contains "${W1_AGENTS}" "member.role: worker" "Worker 1 runtime context has worker role" else - log_pass "Worker 1 team-context does NOT reference Manager" + assert_contains "${W1_AGENTS}" "agentteams-builtin-start" "Worker 1 AGENTS.md has builtin-start" + assert_contains "${W1_AGENTS}" "agentteams-builtin-end" "Worker 1 AGENTS.md has builtin-end" + assert_contains "${W1_AGENTS}" "agentteams-team-context-start" "Worker 1 has team-context block" + assert_contains "${W1_AGENTS}" "@${TEST_LEADER}:" "Worker 1 coordinator is Team Leader" + W1_CTX=$(echo "${W1_AGENTS}" | sed -n '/agentteams-team-context-start/,/agentteams-team-context-end/p') + if echo "${W1_CTX}" | grep -q "@manager:"; then + log_fail "Worker 1 team-context references Manager (should only reference Leader)" + else + log_pass "Worker 1 team-context does NOT reference Manager" + fi + assert_contains "${W1_AGENTS}" "Do NOT @mention Manager" "Worker 1 told not to @mention Manager" fi -assert_contains "${W1_AGENTS}" "Do NOT @mention Manager" "Worker 1 told not to @mention Manager" - # ============================================================ # Section 7: Verify groupAllowFrom # ============================================================ @@ -277,7 +289,7 @@ wait_agent_matrix_allow_contains "${TEST_W1}" ".channels.matrix.groupAllowFrom" wait_agent_matrix_allow_contains "manager" ".channels.matrix.groupAllowFrom" "@${TEST_LEADER}:" 120 || true # Leader: should have [Manager, Admin, W1, W2] -LEADER_GAF=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_LEADER}/openclaw.json" 2>/dev/null | jq -r '.channels.matrix.groupAllowFrom[]' 2>/dev/null) +LEADER_GAF=$(read_worker_matrix_allowlist "${TEST_LEADER}") if echo "${LEADER_GAF}" | grep -q "@manager:"; then log_pass "Leader groupAllowFrom includes Manager" else @@ -293,7 +305,7 @@ for w in "${TEST_W1}" "${TEST_W2}"; do done # Workers: should have [Leader, Admin] but NOT Manager -W1_GAF=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_W1}/openclaw.json" 2>/dev/null | jq -r '.channels.matrix.groupAllowFrom[]' 2>/dev/null) +W1_GAF=$(read_worker_matrix_allowlist "${TEST_W1}") if echo "${W1_GAF}" | grep -q "@${TEST_LEADER}:"; then log_pass "Worker 1 groupAllowFrom includes Leader" else @@ -308,7 +320,7 @@ fi # Peer mentions: Workers should have each other in groupAllowFrom (default peerMentions=true) # EXCEPT: W1 has groupDenyExtra for W2, so W1 should NOT have W2 -W2_GAF=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_W2}/openclaw.json" 2>/dev/null | jq -r '.channels.matrix.groupAllowFrom[]' 2>/dev/null) +W2_GAF=$(read_worker_matrix_allowlist "${TEST_W2}") if echo "${W1_GAF}" | grep -q "@${TEST_W2}:"; then log_fail "Worker 1 groupAllowFrom includes Worker 2 (should be denied by channelPolicy)" @@ -372,25 +384,41 @@ fi # ============================================================ log_section "Verify Skills by Role" -# Leader should have canonical Team Leader skills -for skill in team-coordination project-management task-management; do - LEADER_SKILL=$(exec_in_manager bash -c "mc ls '${STORAGE_PREFIX}/agents/${TEST_LEADER}/skills/${skill}/SKILL.md' >/dev/null 2>&1 && echo yes || echo no") - if [ "${LEADER_SKILL}" = "yes" ]; then - log_pass "Leader has ${skill} skill" - else - log_fail "Leader missing ${skill} skill" - fi -done - -# Workers should have standard worker skills -for skill in file-sync task-progress mcporter; do - W1_SKILL=$(exec_in_manager bash -c "mc ls '${STORAGE_PREFIX}/agents/${TEST_W1}/skills/${skill}/SKILL.md' >/dev/null 2>&1 && echo yes || echo no") - if [ "${W1_SKILL}" = "yes" ]; then - log_pass "Worker 1 has ${skill} skill" - else - log_fail "Worker 1 missing ${skill} skill" - fi -done +if [ "${TEST_WORKER_RUNTIME}" = "qwenpaw" ]; then + LEADER_SKILLS=$(read_qwenpaw_skills "${TEST_LEADER}") + W1_SKILLS=$(read_qwenpaw_skills "${TEST_W1}") + for skill in team-coordination project-management task-management; do + if echo "${LEADER_SKILLS}" | jq -e --arg skill "${skill}" '.[] | select(.name == $skill and .source == "plugin:teamharness")' >/dev/null 2>&1; then + log_pass "Leader has plugin skill ${skill}" + else + log_fail "Leader missing plugin skill ${skill}" + fi + done + for skill in communication file-sharing mcporter; do + if echo "${W1_SKILLS}" | jq -e --arg skill "${skill}" '.[] | select(.name == $skill and .source == "plugin:teamharness")' >/dev/null 2>&1; then + log_pass "Worker 1 has plugin skill ${skill}" + else + log_fail "Worker 1 missing plugin skill ${skill}" + fi + done +else + for skill in team-coordination project-management task-management; do + LEADER_SKILL=$(exec_in_manager bash -c "mc ls '${STORAGE_PREFIX}/agents/${TEST_LEADER}/skills/${skill}/SKILL.md' >/dev/null 2>&1 && echo yes || echo no") + if [ "${LEADER_SKILL}" = "yes" ]; then + log_pass "Leader has ${skill} skill" + else + log_fail "Leader missing ${skill} skill" + fi + done + for skill in file-sync task-progress mcporter; do + W1_SKILL=$(exec_in_manager bash -c "mc ls '${STORAGE_PREFIX}/agents/${TEST_W1}/skills/${skill}/SKILL.md' >/dev/null 2>&1 && echo yes || echo no") + if [ "${W1_SKILL}" = "yes" ]; then + log_pass "Worker 1 has ${skill} skill" + else + log_fail "Worker 1 missing ${skill} skill" + fi + done +fi # ============================================================ # Section 9: Verify agent count diff --git a/tests/test-19-human-and-team-admin.sh b/tests/test-19-human-and-team-admin.sh index eb26b6c68..b7df0078e 100644 --- a/tests/test-19-human-and-team-admin.sh +++ b/tests/test-19-human-and-team-admin.sh @@ -25,6 +25,7 @@ TEST_LEADER="${TEST_TEAM}-lead" TEST_W1="${TEST_TEAM}-dev" TEST_HUMAN="test-human-$$" STORAGE_PREFIX="${STORAGE_PREFIX:-${TEST_STORAGE_PREFIX:-agentteams/agentteams-storage}}" +TEST_WORKER_RUNTIME="${AGENTTEAMS_DEFAULT_WORKER_RUNTIME:-openclaw}" _cleanup() { if [ "${TESTS_FAILED}" -gt 0 ]; then @@ -235,21 +236,25 @@ wait_agent_matrix_allow_contains "${TEST_LEADER}" ".channels.matrix.groupAllowFr wait_agent_matrix_allow_contains "${TEST_LEADER}" ".channels.matrix.dm.allowFrom" "${HUMAN_MATRIX_ID}" 120 || true wait_agent_matrix_allow_contains "${TEST_W1}" ".channels.matrix.groupAllowFrom" "${HUMAN_MATRIX_ID}" 120 || true -LEADER_GAF=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_LEADER}/openclaw.json" 2>/dev/null | jq -r '.channels.matrix.groupAllowFrom[]' 2>/dev/null) +LEADER_GAF=$(read_worker_matrix_allowlist "${TEST_LEADER}") if echo "${LEADER_GAF}" | grep -q "${HUMAN_MATRIX_ID}"; then log_pass "Leader groupAllowFrom includes Team Admin (backfilled)" else log_fail "Leader groupAllowFrom missing Team Admin after backfill" fi -LEADER_DAF=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_LEADER}/openclaw.json" 2>/dev/null | jq -r '.channels.matrix.dm.allowFrom[]' 2>/dev/null) +if [ "${TEST_WORKER_RUNTIME}" = "qwenpaw" ]; then + LEADER_DAF="${LEADER_GAF}" +else + LEADER_DAF=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_LEADER}/openclaw.json" 2>/dev/null | jq -r '.channels.matrix.dm.allowFrom[]' 2>/dev/null) +fi if echo "${LEADER_DAF}" | grep -q "${HUMAN_MATRIX_ID}"; then log_pass "Leader dm.allowFrom includes Team Admin" else log_fail "Leader dm.allowFrom missing Team Admin" fi -W1_GAF=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_W1}/openclaw.json" 2>/dev/null | jq -r '.channels.matrix.groupAllowFrom[]' 2>/dev/null) +W1_GAF=$(read_worker_matrix_allowlist "${TEST_W1}") if echo "${W1_GAF}" | grep -q "${HUMAN_MATRIX_ID}"; then log_pass "Worker groupAllowFrom includes Team Admin (backfilled)" else @@ -267,13 +272,21 @@ fi # ============================================================ log_section "Verify Team Context Block" -W1_AGENTS=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_W1}/AGENTS.md" 2>/dev/null || echo "") -W1_CTX=$(echo "${W1_AGENTS}" | sed -n '/agentteams-team-context-start/,/agentteams-team-context-end/p') -assert_contains "${W1_CTX}" "Team Admin" "Worker team-context mentions Team Admin" - -LEADER_AGENTS=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_LEADER}/AGENTS.md" 2>/dev/null || echo "") -LEADER_CTX=$(echo "${LEADER_AGENTS}" | sed -n '/agentteams-team-context-start/,/agentteams-team-context-end/p') -assert_contains "${LEADER_CTX}" "Team Admin" "Leader team-context mentions Team Admin" +if [ "${TEST_WORKER_RUNTIME}" = "qwenpaw" ]; then + W1_CTX=$(read_worker_runtime_file "${TEST_W1}" "TEAMS.md") + LEADER_CTX=$(read_worker_runtime_file "${TEST_LEADER}" "TEAMS.md") + assert_contains "${W1_CTX}" "team.admin.name: ${TEST_HUMAN}" "Worker runtime context names Team Admin" + assert_contains "${W1_CTX}" "team.admin.matrixUserId: ${HUMAN_MATRIX_ID}" "Worker runtime context has Team Admin Matrix ID" + assert_contains "${LEADER_CTX}" "team.admin.name: ${TEST_HUMAN}" "Leader runtime context names Team Admin" + assert_contains "${LEADER_CTX}" "team.admin.matrixUserId: ${HUMAN_MATRIX_ID}" "Leader runtime context has Team Admin Matrix ID" +else + W1_AGENTS=$(read_worker_runtime_file "${TEST_W1}" "AGENTS.md") + W1_CTX=$(echo "${W1_AGENTS}" | sed -n '/agentteams-team-context-start/,/agentteams-team-context-end/p') + assert_contains "${W1_CTX}" "Team Admin" "Worker team-context mentions Team Admin" + LEADER_AGENTS=$(read_worker_runtime_file "${TEST_LEADER}" "AGENTS.md") + LEADER_CTX=$(echo "${LEADER_AGENTS}" | sed -n '/agentteams-team-context-start/,/agentteams-team-context-end/p') + assert_contains "${LEADER_CTX}" "Team Admin" "Leader team-context mentions Team Admin" +fi # ============================================================ # Section 7: Verify admin auto-joined worker rooms diff --git a/tests/test-24-skills-management.sh b/tests/test-24-skills-management.sh index 77ca39c2e..e4f4205e9 100755 --- a/tests/test-24-skills-management.sh +++ b/tests/test-24-skills-management.sh @@ -22,7 +22,7 @@ test_setup "24-skills-management" TEST_WORKER="test-skl-$$" STORAGE_PREFIX="${STORAGE_PREFIX:-${TEST_STORAGE_PREFIX:-agentteams/agentteams-storage}}" TEST_WORKER_RUNTIME="${AGENTTEAMS_DEFAULT_WORKER_RUNTIME:-openclaw}" -if [ "${TEST_WORKER_RUNTIME}" = "copaw" ]; then +if [ "${TEST_WORKER_RUNTIME}" = "copaw" ] || [ "${TEST_WORKER_RUNTIME}" = "qwenpaw" ]; then BASELINE_SKILL="file-sharing" else BASELINE_SKILL="file-sync" @@ -89,8 +89,16 @@ else log_fail "Worker CR missing 'github-operations' (got: ${INITIAL_SKILLS})" fi -# Built-in baseline skill should be present in MinIO regardless of --skills -if minio_file_exists "agents/${TEST_WORKER}/skills/${BASELINE_SKILL}/SKILL.md"; then +# Built-in baseline skill should be present in the runtime that consumes it. +if [ "${TEST_WORKER_RUNTIME}" = "qwenpaw" ]; then + QWENPAW_SKILLS=$(read_qwenpaw_skills "${TEST_WORKER}") + if echo "${QWENPAW_SKILLS}" | jq -e --arg skill "${BASELINE_SKILL}" \ + '.[] | select(.name == $skill and .source == "plugin:teamharness")' >/dev/null 2>&1; then + log_pass "Built-in plugin skill '${BASELINE_SKILL}' visible through QwenPaw API" + else + log_fail "Built-in plugin skill '${BASELINE_SKILL}' missing from QwenPaw API" + fi +elif minio_file_exists "agents/${TEST_WORKER}/skills/${BASELINE_SKILL}/SKILL.md"; then log_pass "Built-in skill '${BASELINE_SKILL}' present in MinIO for ${TEST_WORKER_RUNTIME} runtime" else log_fail "Built-in skill '${BASELINE_SKILL}' missing in MinIO for ${TEST_WORKER_RUNTIME} runtime" From b8fc7f6423cd1017336b0abb3030cbf0ee419a4d Mon Sep 17 00:00:00 2001 From: yuhuafeng Date: Tue, 28 Jul 2026 09:05:23 +0800 Subject: [PATCH 35/37] fix(qwenpaw): reconcile team policy in runtime tests --- .../internal/controller/team_controller.go | 6 +- .../controller/team_controller_test.go | 17 ++++- changelog/current.md | 1 + tests/lib/test-helpers.sh | 45 +++++++++++ tests/test-17-worker-config-verify.sh | 26 +++++-- tests/test-18-team-config-verify.sh | 13 +++- tests/test-20-inline-worker-config.sh | 74 ++++++++++++------- tests/test-22-delete-worker-cleanup.sh | 12 ++- tests/test-24-skills-management.sh | 8 ++ 9 files changed, 166 insertions(+), 36 deletions(-) diff --git a/agentteams-controller/internal/controller/team_controller.go b/agentteams-controller/internal/controller/team_controller.go index b03f71b38..9318feb26 100644 --- a/agentteams-controller/internal/controller/team_controller.go +++ b/agentteams-controller/internal/controller/team_controller.go @@ -656,13 +656,17 @@ func (r *TeamReconciler) deployTeamRuntimeConfigs( if err != nil { return err } + spec := member.worker.Spec + if runtime == backend.RuntimeQwenPaw { + spec.ChannelPolicy = mergeChannelPolicy(t.Spec.ChannelPolicy, member.worker.Spec.ChannelPolicy) + } req := service.MemberRuntimeConfigDeployRequest{ Name: member.ref.Name, RuntimeName: member.runtimeName, Runtime: runtime, Role: role.String(), Generation: member.worker.Generation, - Spec: member.worker.Spec, + Spec: spec, AIGatewayURL: aiGatewayURL, MatrixUserID: member.worker.Status.MatrixUserID, PersonalRoomID: member.worker.Status.RoomID, diff --git a/agentteams-controller/internal/controller/team_controller_test.go b/agentteams-controller/internal/controller/team_controller_test.go index c1ba3e898..2cc925f05 100644 --- a/agentteams-controller/internal/controller/team_controller_test.go +++ b/agentteams-controller/internal/controller/team_controller_test.go @@ -442,7 +442,13 @@ func TestReconcileTeamTeamReferences_QwenPawProjectsRuntimeRoster(t *testing.T) } worker1 := &v1beta1.Worker{ ObjectMeta: metav1.ObjectMeta{Name: "dev", Namespace: "default"}, - Spec: v1beta1.WorkerSpec{Runtime: "qwenpaw", Model: "qwen"}, + Spec: v1beta1.WorkerSpec{ + Runtime: "qwenpaw", + Model: "qwen", + ChannelPolicy: &v1beta1.ChannelPolicySpec{ + DmAllowExtra: []string{"worker-dm-bot"}, + }, + }, Status: v1beta1.WorkerStatus{ Phase: "Running", MatrixUserID: "@dev:matrix.local", @@ -462,6 +468,7 @@ func TestReconcileTeamTeamReferences_QwenPawProjectsRuntimeRoster(t *testing.T) ObjectMeta: metav1.ObjectMeta{Name: "team-a", Namespace: "default"}, Spec: v1beta1.TeamSpec{ Admin: &v1beta1.TeamAdminSpec{Name: "admin", MatrixUserID: "@admin:localhost"}, + ChannelPolicy: &v1beta1.ChannelPolicySpec{GroupAllowExtra: []string{"team-group-bot"}}, HumanMembers: []v1beta1.TeamMemberSpec{{Name: "human-coord", MatrixUserID: "@human:matrix.local"}}, WorkerMembers: []v1beta1.TeamWorkerRef{ {Name: "lead", Role: "team_leader"}, @@ -508,6 +515,14 @@ func TestReconcileTeamTeamReferences_QwenPawProjectsRuntimeRoster(t *testing.T) if leaderReq.TeamRoomID == "" || leaderReq.LeaderDMRoomID == "" { t.Fatalf("leader runtime config missing rooms: %#v", leaderReq) } + if leaderReq.Spec.ChannelPolicy == nil || !stringSliceContains(leaderReq.Spec.ChannelPolicy.GroupAllowExtra, "team-group-bot") { + t.Fatalf("leader runtime config channel policy=%#v, want team-level group allow", leaderReq.Spec.ChannelPolicy) + } + if devReq.Spec.ChannelPolicy == nil || + !stringSliceContains(devReq.Spec.ChannelPolicy.GroupAllowExtra, "team-group-bot") || + !stringSliceContains(devReq.Spec.ChannelPolicy.DmAllowExtra, "worker-dm-bot") { + t.Fatalf("dev runtime config channel policy=%#v, want merged team and worker policy", devReq.Spec.ChannelPolicy) + } roster := map[string]service.RuntimeConfigTeamMember{} for _, member := range leaderReq.TeamMembers { roster[member.Name] = member diff --git a/changelog/current.md b/changelog/current.md index 42088b265..e2e4280d1 100644 --- a/changelog/current.md +++ b/changelog/current.md @@ -6,6 +6,7 @@ Record image-affecting changes to `manager/`, `worker/`, `copaw/`, `hermes/`, `o **Bug Fixes** +- **QwenPaw Team policy and runtime-aware acceptance**: Merge Team and Worker channel-policy overrides into QwenPaw `runtime.yaml`, wait for public plugin/API state before integration assertions, and verify prompt/config files from the runtime location that consumes them. - **QwenPaw 2.0 tool execution**: Preserve QwenPaw's asynchronous tool-result stream while sanitizing output, and allow only the built-in TeamHarness and Workerflow MCP drivers to run without an unavailable interactive approval prompt. - **QwenPaw package and Team storage access**: Preserve referenced members' effective Team name across independent Worker reconciles, update MinIO policies without detaching active Workers, revoke access on detach, and grant Workers read-only access to centrally uploaded AgentSpec packages. - **QwenPaw inline prompt compatibility**: Project Worker identity, SOUL, and AGENTS content through runtime desired state and apply it to both the native QwenPaw workspace and the Worker root storage contract. diff --git a/tests/lib/test-helpers.sh b/tests/lib/test-helpers.sh index b369dd56c..9ded23928 100755 --- a/tests/lib/test-helpers.sh +++ b/tests/lib/test-helpers.sh @@ -419,6 +419,27 @@ read_worker_runtime_file() { exec_in_manager mc cat "${storage_prefix}/agents/${agent_name}/${rel_path}" 2>/dev/null || true } +# wait_worker_runtime_file_contains [timeout_seconds] +wait_worker_runtime_file_contains() { + local agent_name="$1" + local rel_path="$2" + local needle="$3" + local timeout="${4:-180}" + local elapsed=0 + local last="" + while [ "${elapsed}" -lt "${timeout}" ]; do + last=$(read_worker_runtime_file "${agent_name}" "${rel_path}") + if echo "${last}" | grep -Fq "${needle}"; then + return 0 + fi + sleep 5 + elapsed=$((elapsed + 5)) + done + echo "wait_worker_runtime_file_contains: agent=${agent_name} path=${rel_path} timed out after ${timeout}s, want='${needle}'" >&2 + dump_diagnostics worker "${agent_name}" + return 1 +} + # Read the effective Matrix allow-list. QwenPaw applies the Controller policy # through the public ACL API; other runtimes persist it in openclaw.json. read_worker_matrix_allowlist() { @@ -441,6 +462,30 @@ read_qwenpaw_skills() { curl -sf http://127.0.0.1:8088/api/skills 2>/dev/null || printf '[]\n' } +# wait_qwenpaw_api_matches [timeout_seconds] +# Waits for the running QwenPaw app to expose reconciled public API state. +wait_qwenpaw_api_matches() { + local agent_name="$1" + local path="$2" + local jq_filter="$3" + local timeout="${4:-180}" + local elapsed=0 + local container="" + local response="" + while [ "${elapsed}" -lt "${timeout}" ]; do + container="$(worker_container_name "${agent_name}")" + response=$(docker exec "${container}" curl -sf "http://127.0.0.1:8088${path}" 2>/dev/null || true) + if [ -n "${response}" ] && echo "${response}" | jq -e "${jq_filter}" >/dev/null 2>&1; then + return 0 + fi + sleep 5 + elapsed=$((elapsed + 5)) + done + echo "wait_qwenpaw_api_matches: agent=${agent_name} path=${path} timed out after ${timeout}s" >&2 + dump_diagnostics worker "${agent_name}" + return 1 +} + # wait_agent_matrix_allow_contains [timeout_seconds] # Polls the selected runtime's effective Matrix allow-list until it contains # the expected ID. QwenPaw intentionally exposes one channel-scoped ACL for diff --git a/tests/test-17-worker-config-verify.sh b/tests/test-17-worker-config-verify.sh index 0aeb3057b..9845985e1 100644 --- a/tests/test-17-worker-config-verify.sh +++ b/tests/test-17-worker-config-verify.sh @@ -25,7 +25,7 @@ if [ "${TEST_WORKER_RUNTIME}" = "copaw" ]; then FILE_SHARING_SKILL="file-sharing" elif [ "${TEST_WORKER_RUNTIME}" = "qwenpaw" ]; then BUILTIN_CONTENT_SENTINEL="Session files are runtime-private state" - BUILTIN_SKILLS=(communication file-sharing find-skills mcporter organization task-management) + BUILTIN_SKILLS=(communication file-sharing team-coordination project-management task-delegation task-execution mcporter) FILE_SHARING_SKILL="file-sharing" else BUILTIN_CONTENT_SENTINEL="Every Session" @@ -122,6 +122,15 @@ else log_fail "Controller did not reconcile within 120s" fi +if [ "${TEST_WORKER_RUNTIME}" = "qwenpaw" ]; then + if wait_qwenpaw_api_matches "${TEST_WORKER}" /api/teamharness/health '.ok == true and .adapter == "qwenpaw-2"' 240 && \ + wait_qwenpaw_api_matches "${TEST_WORKER}" /api/skills '.[] | select(.name == "my-custom-skill" and .enabled == true)' 240; then + log_pass "QwenPaw app, TeamHarness plugin, and AgentPackage are ready" + else + log_fail "QwenPaw runtime did not expose reconciled plugin/package state" + fi +fi + # ============================================================ # Section 2: Verify AGENTS.md structure # ============================================================ @@ -138,9 +147,12 @@ if [ "${TEST_WORKER_RUNTIME}" != "qwenpaw" ]; then assert_contains "${AGENTS_CONTENT}" "agentteams-builtin-end" "AGENTS.md has builtin-end marker" fi -# Builtin content filled (not empty between markers) -assert_contains "${AGENTS_CONTENT}" "${BUILTIN_CONTENT_SENTINEL}" \ - "AGENTS.md builtin section matches ${TEST_WORKER_RUNTIME} runtime" +# QwenPaw injects the built-in prompt through TeamHarness' public prompt hook; +# its workspace AGENTS.md intentionally contains only the AgentPackage prompt. +if [ "${TEST_WORKER_RUNTIME}" != "qwenpaw" ]; then + assert_contains "${AGENTS_CONTENT}" "${BUILTIN_CONTENT_SENTINEL}" \ + "AGENTS.md builtin section matches ${TEST_WORKER_RUNTIME} runtime" +fi if [ "${TEST_WORKER_RUNTIME}" != "qwenpaw" ]; then assert_contains "${AGENTS_CONTENT}" "agentteams-team-context-start" "AGENTS.md has team-context-start marker" @@ -337,7 +349,11 @@ if [ "${TEST_WORKER_RUNTIME}" != "qwenpaw" ]; then assert_contains "${AGENTS_AFTER}" "agentteams-builtin-start" "AGENTS.md still has builtin markers after update" assert_contains "${AGENTS_AFTER}" "agentteams-team-context-start" "AGENTS.md still has team-context after update" else - assert_contains "${AGENTS_AFTER}" "Session files are runtime-private state" "QwenPaw runtime prompt policy remains after update" + if wait_qwenpaw_api_matches "${TEST_WORKER}" /api/teamharness/health '.ok == true and .adapter == "qwenpaw-2"' 180; then + log_pass "QwenPaw TeamHarness prompt plugin remains active after update" + else + log_fail "QwenPaw TeamHarness prompt plugin unavailable after update" + fi fi assert_contains "${AGENTS_AFTER}" "My Custom Agent Instructions" "User content still preserved after update" diff --git a/tests/test-18-team-config-verify.sh b/tests/test-18-team-config-verify.sh index f58d55b26..cb38096f4 100644 --- a/tests/test-18-team-config-verify.sh +++ b/tests/test-18-team-config-verify.sh @@ -162,6 +162,17 @@ for w in "${TEST_LEADER}" "${TEST_W1}" "${TEST_W2}"; do fi done +if [ "${TEST_WORKER_RUNTIME}" = "qwenpaw" ]; then + for w in "${TEST_LEADER}" "${TEST_W1}" "${TEST_W2}"; do + if wait_qwenpaw_api_matches "${w}" /api/teamharness/health '.ok == true and .adapter == "qwenpaw-2"' 240 && \ + wait_worker_runtime_file_contains "${w}" "TEAMS.md" "BEGIN AGENTTEAMS RUNTIME TEAM CONTEXT" 240; then + log_pass "QwenPaw TeamHarness plugin ready for ${w}" + else + log_fail "QwenPaw TeamHarness plugin not ready for ${w}" + fi + done +fi + # ============================================================ # Section 3: Verify Team resource # ============================================================ @@ -387,7 +398,7 @@ log_section "Verify Skills by Role" if [ "${TEST_WORKER_RUNTIME}" = "qwenpaw" ]; then LEADER_SKILLS=$(read_qwenpaw_skills "${TEST_LEADER}") W1_SKILLS=$(read_qwenpaw_skills "${TEST_W1}") - for skill in team-coordination project-management task-management; do + for skill in team-coordination project-management task-delegation task-execution; do if echo "${LEADER_SKILLS}" | jq -e --arg skill "${skill}" '.[] | select(.name == $skill and .source == "plugin:teamharness")' >/dev/null 2>&1; then log_pass "Leader has plugin skill ${skill}" else diff --git a/tests/test-20-inline-worker-config.sh b/tests/test-20-inline-worker-config.sh index 61eba1480..bd9b4ab53 100755 --- a/tests/test-20-inline-worker-config.sh +++ b/tests/test-20-inline-worker-config.sh @@ -7,7 +7,7 @@ # 3. Controller reconcile: mc mirror → fsnotify → kine → WorkerReconciler # 4. WriteInlineConfigs generates SOUL.md + AGENTS.md # 5. create-worker.sh runs: Matrix account + Room + container -# 6. Verify SOUL.md and AGENTS.md content in MinIO +# 6. Verify SOUL.md and AGENTS.md in the selected runtime's consumed location SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/lib/test-helpers.sh" @@ -18,6 +18,7 @@ test_setup "20-inline-worker-config" TEST_WORKER="test-inline-$$" TEST_WORKER_OVERRIDE="test-inlover-$$" STORAGE_PREFIX="${STORAGE_PREFIX:-${TEST_STORAGE_PREFIX:-agentteams/agentteams-storage}}" +TEST_WORKER_RUNTIME="${AGENTTEAMS_DEFAULT_WORKER_RUNTIME:-openclaw}" # ---- Cleanup handler ---- _cleanup() { @@ -144,27 +145,42 @@ else exec_in_agent agt get workers "${TEST_WORKER}" -o json 2>/dev/null | jq -r '.phase, .message' | head -5 fi -# Verify inline configs were written -INLINE_LOG=$(exec_in_manager cat /var/log/agentteams/agentteams-controller-error.log 2>/dev/null | grep "inline configs written.*${TEST_WORKER}" || echo "") -assert_not_empty "${INLINE_LOG}" "Controller logged inline configs written" +# QwenPaw consumes inline config from runtime.yaml; legacy runtimes still use +# the controller's direct file writer and its corresponding log event. +if [ "${TEST_WORKER_RUNTIME}" = "qwenpaw" ]; then + wait_agent_file_contains "${TEST_WORKER}" "runtime/runtime.yaml" "Inline Test Worker" 180 || true + RUNTIME_CONFIG=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_WORKER}/runtime/runtime.yaml" 2>/dev/null || true) + assert_contains "${RUNTIME_CONFIG}" "Inline Test Worker" "runtime.yaml carries inline SOUL config" + if wait_qwenpaw_api_matches "${TEST_WORKER}" /api/teamharness/health '.ok == true and .adapter == "qwenpaw-2"' 240; then + log_pass "QwenPaw runtime and TeamHarness plugin are ready" + else + log_fail "QwenPaw runtime did not become ready" + fi +else + INLINE_LOG=$(exec_in_manager cat /var/log/agentteams/agentteams-controller-error.log 2>/dev/null | grep "inline configs written.*${TEST_WORKER}" || echo "") + assert_not_empty "${INLINE_LOG}" "Controller logged inline configs written" +fi # ============================================================ # Section 6: Verify SOUL.md and AGENTS.md content # ============================================================ log_section "Verify Inline Config Files" -# Check SOUL.md in MinIO -SOUL_IN_MINIO=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_WORKER}/SOUL.md" 2>/dev/null || echo "") -assert_not_empty "${SOUL_IN_MINIO}" "SOUL.md exists in MinIO agent space" -assert_contains "${SOUL_IN_MINIO}" "Inline Test Worker" "SOUL.md contains expected content" -assert_contains "${SOUL_IN_MINIO}" "AI Identity" "SOUL.md contains AI Identity section" - -# Check AGENTS.md in MinIO -AGENTS_IN_MINIO=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_WORKER}/AGENTS.md" 2>/dev/null || echo "") -assert_not_empty "${AGENTS_IN_MINIO}" "AGENTS.md exists in MinIO agent space" -assert_contains "${AGENTS_IN_MINIO}" "Inline Test Workspace" "AGENTS.md contains expected content" -assert_contains "${AGENTS_IN_MINIO}" "agentteams-builtin-start" "AGENTS.md has builtin markers" -assert_contains "${AGENTS_IN_MINIO}" "agentteams-builtin-end" "AGENTS.md has builtin end marker" +# Check the files from the runtime location that consumes them. +wait_worker_runtime_file_contains "${TEST_WORKER}" "SOUL.md" "Inline Test Worker" 180 || true +SOUL_IN_RUNTIME=$(read_worker_runtime_file "${TEST_WORKER}" "SOUL.md") +assert_not_empty "${SOUL_IN_RUNTIME}" "SOUL.md exists in Worker runtime" +assert_contains "${SOUL_IN_RUNTIME}" "Inline Test Worker" "SOUL.md contains expected content" +assert_contains "${SOUL_IN_RUNTIME}" "AI Identity" "SOUL.md contains AI Identity section" + +wait_worker_runtime_file_contains "${TEST_WORKER}" "AGENTS.md" "Inline Test Workspace" 180 || true +AGENTS_IN_RUNTIME=$(read_worker_runtime_file "${TEST_WORKER}" "AGENTS.md") +assert_not_empty "${AGENTS_IN_RUNTIME}" "AGENTS.md exists in Worker runtime" +assert_contains "${AGENTS_IN_RUNTIME}" "Inline Test Workspace" "AGENTS.md contains expected content" +if [ "${TEST_WORKER_RUNTIME}" != "qwenpaw" ]; then + assert_contains "${AGENTS_IN_RUNTIME}" "agentteams-builtin-start" "AGENTS.md has builtin markers" + assert_contains "${AGENTS_IN_RUNTIME}" "agentteams-builtin-end" "AGENTS.md has builtin end marker" +fi # ============================================================ # Section 7: Verify Worker infrastructure @@ -179,12 +195,20 @@ assert_not_empty "${WORKER_RESOURCE}" "Worker resource is queryable" ROOM_ID=$(exec_in_agent agt get workers "${TEST_WORKER}" -o json 2>/dev/null | jq -r '.roomID // empty') assert_not_empty "${ROOM_ID}" "Matrix Room created: ${ROOM_ID}" -# openclaw.json in MinIO -OPENCLAW_EXISTS=$(exec_in_manager bash -c "mc ls '${STORAGE_PREFIX}/agents/${TEST_WORKER}/openclaw.json' >/dev/null 2>&1 && echo yes || echo no") -if [ "${OPENCLAW_EXISTS}" = "yes" ]; then - log_pass "openclaw.json generated and pushed to MinIO" +# Runtime desired state in object storage +if [ "${TEST_WORKER_RUNTIME}" = "qwenpaw" ]; then + if echo "${RUNTIME_CONFIG}" | grep -Fq 'runtime: qwenpaw'; then + log_pass "runtime.yaml generated with qwenpaw runtime" + else + log_fail "runtime.yaml missing qwenpaw runtime declaration" + fi else - log_fail "openclaw.json not found in MinIO" + OPENCLAW_EXISTS=$(exec_in_manager bash -c "mc ls '${STORAGE_PREFIX}/agents/${TEST_WORKER}/openclaw.json' >/dev/null 2>&1 && echo yes || echo no") + if [ "${OPENCLAW_EXISTS}" = "yes" ]; then + log_pass "openclaw.json generated and pushed to MinIO" + else + log_fail "openclaw.json not found in MinIO" + fi fi # Worker container running. @@ -341,15 +365,15 @@ fi # Wait for the update reconcile. The durable signal is the generated file # content; log text can change across controller versions. log_info "Waiting for controller to reconcile override worker update..." -if wait_agent_file_contains "${TEST_WORKER_OVERRIDE}" "SOUL.md" "OVERRIDDEN SOUL FROM INLINE" 120; then +if wait_worker_runtime_file_contains "${TEST_WORKER_OVERRIDE}" "SOUL.md" "OVERRIDDEN SOUL FROM INLINE" 180; then log_pass "Override worker updated" else - log_fail "Override worker not updated within 120s" + log_fail "Override worker not updated within 180s" exec_in_agent agt get workers "${TEST_WORKER_OVERRIDE}" -o json 2>/dev/null | jq -r '.phase, .message' | head -5 fi # Verify SOUL.md has inline content, NOT package content -SOUL_OVERRIDE=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_WORKER_OVERRIDE}/SOUL.md" 2>/dev/null || echo "") +SOUL_OVERRIDE=$(read_worker_runtime_file "${TEST_WORKER_OVERRIDE}" "SOUL.md") assert_not_empty "${SOUL_OVERRIDE}" "SOUL.md exists for override worker" assert_contains "${SOUL_OVERRIDE}" "OVERRIDDEN SOUL FROM INLINE" "SOUL.md contains inline override content" @@ -361,7 +385,7 @@ else fi # Verify AGENTS.md has inline content -AGENTS_OVERRIDE=$(exec_in_manager mc cat "${STORAGE_PREFIX}/agents/${TEST_WORKER_OVERRIDE}/AGENTS.md" 2>/dev/null || echo "") +AGENTS_OVERRIDE=$(read_worker_runtime_file "${TEST_WORKER_OVERRIDE}" "AGENTS.md") assert_not_empty "${AGENTS_OVERRIDE}" "AGENTS.md exists for override worker" assert_contains "${AGENTS_OVERRIDE}" "OVERRIDDEN AGENTS FROM INLINE" "AGENTS.md contains inline override content" diff --git a/tests/test-22-delete-worker-cleanup.sh b/tests/test-22-delete-worker-cleanup.sh index 1bb956374..60255fd27 100644 --- a/tests/test-22-delete-worker-cleanup.sh +++ b/tests/test-22-delete-worker-cleanup.sh @@ -122,10 +122,16 @@ if _get_higress_consumers_or_fail "pre-delete snapshot"; then fi fi -if minio_file_exists "agents/${TEST_WORKER}/SOUL.md"; then - log_pass "MinIO SOUL.md exists before delete" +if [ "${AGENTTEAMS_DEFAULT_WORKER_RUNTIME:-openclaw}" = "qwenpaw" ]; then + wait_worker_runtime_file_contains "${TEST_WORKER}" "SOUL.md" "Session files are runtime-private state" 180 || true else - log_fail "MinIO SOUL.md missing before delete (cannot test cleanup)" + wait_worker_runtime_file_contains "${TEST_WORKER}" "SOUL.md" "${TEST_WORKER}" 180 || true +fi +SOUL_BEFORE_DELETE=$(read_worker_runtime_file "${TEST_WORKER}" "SOUL.md") +if [ -n "${SOUL_BEFORE_DELETE}" ]; then + log_pass "SOUL.md exists in Worker runtime before delete" +else + log_fail "SOUL.md missing from Worker runtime before delete (cannot test cleanup)" fi PRE_YAML=$(_minio_worker_yaml) diff --git a/tests/test-24-skills-management.sh b/tests/test-24-skills-management.sh index e4f4205e9..787b44d79 100755 --- a/tests/test-24-skills-management.sh +++ b/tests/test-24-skills-management.sh @@ -89,6 +89,14 @@ else log_fail "Worker CR missing 'github-operations' (got: ${INITIAL_SKILLS})" fi +if [ "${TEST_WORKER_RUNTIME}" = "qwenpaw" ]; then + if wait_qwenpaw_api_matches "${TEST_WORKER}" /api/skills ".[] | select(.name == \"${BASELINE_SKILL}\" and .source == \"plugin:teamharness\")" 240; then + log_pass "QwenPaw plugin skills reconciled" + else + log_fail "QwenPaw plugin skills did not reconcile" + fi +fi + # Built-in baseline skill should be present in the runtime that consumes it. if [ "${TEST_WORKER_RUNTIME}" = "qwenpaw" ]; then QWENPAW_SKILLS=$(read_qwenpaw_skills "${TEST_WORKER}") From 2a2d0f620bd43591da3c0c510bad90dc06bc3334 Mon Sep 17 00:00:00 2001 From: yuhuafeng Date: Tue, 28 Jul 2026 10:39:20 +0800 Subject: [PATCH 36/37] fix(qwenpaw): persist MCP policies before runtime reload --- changelog/current.md | 1 + qwenpaw/src/qwenpaw_worker/worker.py | 2 +- qwenpaw/tests/test_worker_lifecycle.py | 62 ++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/changelog/current.md b/changelog/current.md index e2e4280d1..d0e728ad4 100644 --- a/changelog/current.md +++ b/changelog/current.md @@ -6,6 +6,7 @@ Record image-affecting changes to `manager/`, `worker/`, `copaw/`, `hermes/`, `o **Bug Fixes** +- **QwenPaw MCP policy startup convergence**: Persist built-in plugin MCP policies before runtime desired-state reloads so a replacement QwenPaw workspace cannot retain the pre-policy interactive approval handler. - **QwenPaw Team policy and runtime-aware acceptance**: Merge Team and Worker channel-policy overrides into QwenPaw `runtime.yaml`, wait for public plugin/API state before integration assertions, and verify prompt/config files from the runtime location that consumes them. - **QwenPaw 2.0 tool execution**: Preserve QwenPaw's asynchronous tool-result stream while sanitizing output, and allow only the built-in TeamHarness and Workerflow MCP drivers to run without an unavailable interactive approval prompt. - **QwenPaw package and Team storage access**: Preserve referenced members' effective Team name across independent Worker reconciles, update MinIO policies without detaching active Workers, revoke access on detach, and grant Workers read-only access to centrally uploaded AgentSpec packages. diff --git a/qwenpaw/src/qwenpaw_worker/worker.py b/qwenpaw/src/qwenpaw_worker/worker.py index bf46e5892..801bbf8c7 100644 --- a/qwenpaw/src/qwenpaw_worker/worker.py +++ b/qwenpaw/src/qwenpaw_worker/worker.py @@ -641,6 +641,7 @@ async def _run_qwenpaw(self) -> None: "QwenPaw_QA_Agent_0.2", ) await asyncio.to_thread(self._configure_builtin_plugin_mcp_clients) + await asyncio.to_thread(self._configure_builtin_plugin_mcp_policies) runtime_config = self._initial_runtime_config or self.updater.load() stage_started = self._log_worker_stage_begin("apply_desired_state") await asyncio.to_thread( @@ -649,7 +650,6 @@ async def _run_qwenpaw(self) -> None: True, False, ) - await asyncio.to_thread(self._configure_builtin_plugin_mcp_policies) self._ensure_session_file_prompt_policy() self._log_worker_stage_complete("apply_desired_state", stage_started) except Exception as exc: diff --git a/qwenpaw/tests/test_worker_lifecycle.py b/qwenpaw/tests/test_worker_lifecycle.py index 0f592ebec..85a8d1cac 100644 --- a/qwenpaw/tests/test_worker_lifecycle.py +++ b/qwenpaw/tests/test_worker_lifecycle.py @@ -608,6 +608,68 @@ def put_mcp_policy(self, key, payload): assert expected_storage_env.items() <= created["teamharness"]["env"].items() +@pytest.mark.anyio +async def test_builtin_mcp_policy_is_persisted_before_desired_state_reload( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + worker = Worker(_config(tmp_path)) + events: list[str] = [] + + class FakeProcess: + pid = 12345 + returncode = None + + async def wait(self): + self.returncode = 0 + return self.returncode + + def terminate(self): + self.returncode = 0 + + class FakeApi: + def configure_agent(self, *_args, **_kwargs): + events.append("agent") + + def disable_agent_if_present(self, *_args, **_kwargs): + return False + + async def fake_create_subprocess_exec(*_args, **_kwargs): + return FakeProcess() + + async def api_ready(): + return None + + async def idle_heartbeat_probe_loop(): + await asyncio.Event().wait() + + monkeypatch.setattr("qwenpaw_worker.worker.asyncio.create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr(worker, "_wait_for_qwenpaw_api", api_ready) + monkeypatch.setattr( + worker, + "_configure_builtin_plugin_mcp_clients", + lambda: events.append("clients"), + ) + monkeypatch.setattr( + worker, + "_configure_builtin_plugin_mcp_policies", + lambda: events.append("policies"), + ) + monkeypatch.setattr(worker, "_ensure_session_file_prompt_policy", lambda: None) + monkeypatch.setattr(worker, "_heartbeat_probe_loop", idle_heartbeat_probe_loop) + worker.api_client = FakeApi() + worker.updater.apply_once = lambda *_args, **_kwargs: events.append("desired") + worker._initial_runtime_config = MemberRuntimeConfig( + path=worker.config.runtime_config_path, + raw={"metadata": {"generation": "1"}, "member": {"runtime": "qwenpaw"}}, + ) + + await worker._run_qwenpaw() + await worker.stop() + + assert events.index("policies") < events.index("desired") + + def test_runtime_updater_uses_default_workspace_for_package_materialization( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, From 60a8c883a124ee3b6567fe96230a0b9e27c95c13 Mon Sep 17 00:00:00 2001 From: yuhuafeng Date: Tue, 28 Jul 2026 10:39:41 +0800 Subject: [PATCH 37/37] test(qwenpaw): isolate phased worker mentions --- tests/test-14-git-collab.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test-14-git-collab.sh b/tests/test-14-git-collab.sh index 85882405f..67ddf71ed 100755 --- a/tests/test-14-git-collab.sh +++ b/tests/test-14-git-collab.sh @@ -108,6 +108,12 @@ Before starting any phase: If a worker already exists, reuse it. 2. Create a shared project room that includes alice, bob, charlie, and the human admin (use the create-project.sh script). All phase assignments and reports MUST happen in this project room — never in individual worker rooms. +Matrix mention isolation is mandatory in the project room: +- You may post the overall project plan without mentioning any Worker. +- Send each executable phase assignment as a separate message that mentions exactly one Worker: the Worker assigned to that phase. +- Never mention multiple Workers in one assignment message, never prefix an assignment with a participant roll-call such as 'alice bob charlie', and never include future-phase Worker names in the current assignment message. +- Creating the room with all participants does not assign work. After room creation, the first actionable message must mention only alice and contain only Phase 1 instructions. + Run the phases strictly in order, waiting for each phase's report before starting the next. **Phase 1 — alice (and only alice)**: