diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml index 5f7ceef6b..51b01df59 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/Makefile b/Makefile index 294b529c1..b625e1ae9 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/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/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/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/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/agentteams-controller/internal/controller/member_reconcile.go b/agentteams-controller/internal/controller/member_reconcile.go index 0cacc174c..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) } @@ -753,7 +754,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 +776,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 +802,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..22cc4aee0 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" @@ -93,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. @@ -131,6 +158,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/agentteams-controller/internal/controller/team_controller.go b/agentteams-controller/internal/controller/team_controller.go index 9cee12604..9318feb26 100644 --- a/agentteams-controller/internal/controller/team_controller.go +++ b/agentteams-controller/internal/controller/team_controller.go @@ -393,6 +393,30 @@ 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 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, + 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) @@ -522,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 @@ -597,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 @@ -612,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, @@ -739,6 +787,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 e5f723e74..2cc925f05 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) { @@ -438,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", @@ -458,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"}, @@ -504,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 @@ -847,6 +866,23 @@ 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) + } + 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 { t.Fatalf("SyncTeamLeaderAssets calls=%d, want 1", len(deployer.Calls.SyncTeamLeaderAssets)) } @@ -914,6 +950,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..2000f8d2e 100644 --- a/agentteams-controller/internal/controller/worker_controller.go +++ b/agentteams-controller/internal/controller/worker_controller.go @@ -198,6 +198,15 @@ 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 + } + 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) @@ -206,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 @@ -244,9 +257,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, configContext, 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 @@ -270,9 +287,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, configContext, 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) @@ -311,6 +332,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..44f28baab 100644 --- a/agentteams-controller/internal/controller/worker_controller_test.go +++ b/agentteams-controller/internal/controller/worker_controller_test.go @@ -101,6 +101,92 @@ 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) + } +} + +func TestWorkerReconcileDoesNotOverwriteTeamOwnedRuntimeConfig(t *testing.T) { + 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.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()) + } +} + type workerTestGateway struct { modelInfo *gateway.ModelProviderInfo modelErr error 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..578cd306c 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) { @@ -259,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 ` @@ -288,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 211d36e56..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 @@ -204,6 +205,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 +230,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/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/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 6436a6d7d..d0e728ad4 100644 --- a/changelog/current.md +++ b/changelog/current.md @@ -6,6 +6,13 @@ 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. +- **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. - **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 +22,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/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/hooks/message_filter.py b/copaw/src/copaw_worker/hooks/message_filter.py index 692d7e624..0676d145e 100644 --- a/copaw/src/copaw_worker/hooks/message_filter.py +++ b/copaw/src/copaw_worker/hooks/message_filter.py @@ -73,15 +73,56 @@ 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": 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/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/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/copaw/tests/test_message_tool.py b/copaw/tests/test_message_tool.py index 2e58ee3dd..89838a63c 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,48 @@ 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, + 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, 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 e6e29412b..4812bc629 100755 --- a/install/agentteams-install.sh +++ b/install/agentteams-install.sh @@ -1080,6 +1080,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:-}" @@ -1089,6 +1090,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 @@ -3467,9 +3469,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) @@ -3644,6 +3647,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 --- @@ -3839,6 +3843,7 @@ CREDEOF -e "${_ctrl_env_prefix}DEFAULT_WORKER_RUNTIME=${AGENTTEAMS_DEFAULT_WORKER_RUNTIME:-copaw}" -e "${_ctrl_env_prefix}WORKER_IMAGE=${WORKER_IMAGE}" -e "${_ctrl_env_prefix}COPAW_WORKER_IMAGE=${COPAW_WORKER_IMAGE}" + -e "${_ctrl_env_prefix}QWENPAW_WORKER_IMAGE=${QWENPAW_WORKER_IMAGE}" -e "${_ctrl_env_prefix}HERMES_WORKER_IMAGE=${HERMES_WORKER_IMAGE}" -e "${_ctrl_env_prefix}MATRIX_DOMAIN=${_matrix_domain}" -e "${_ctrl_env_prefix}ELEMENT_HOMESERVER_URL=http://127.0.0.1:${AGENTTEAMS_PORT_GATEWAY}" @@ -4086,6 +4091,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 \ 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 9582f414a..b279103a4 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 @@ -66,7 +60,7 @@ WhoamiResponse, ) -from agentscope_runtime.engine.schemas.agent_schemas import ( +from qwenpaw.schemas import ( AudioContent, ContentType, FileContent, @@ -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", ""), @@ -3973,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) @@ -3997,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/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/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 4ed8c80a4..ca8c3477f 100644 --- a/plugins/teamharness/adapters/qwenpaw/plugin.py +++ b/plugins/teamharness/adapters/qwenpaw/plugin.py @@ -1,1043 +1,204 @@ -#!/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]: +def _team_prompt(_agent: Any) -> str: + path = ASSET_DIR / "prompts" / "team" / "TEAMS.md" 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]: - 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]: - 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: - working_dir = _qwenpaw_working_dir() - 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]]: - 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 "" - 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 + _sanitize(item) -def _enable_workspace_skills(workspace_dir: Path, role: str) -> Dict[str, Any]: - installed = [] - failed = [] +def _sanitizer_factory(_ctx: Any, _agent_config: Any): try: - from qwenpaw.agents.skill_system.pool_service import SkillPoolService + from agentscope.middleware import MiddlewareBase 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} - - -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]: - try: - from qwenpaw.config.config import MCPClientConfig, MCPConfig, load_agent_config, save_agent_config - 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]: + del agent + 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 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): - result = await original(self, tool_call) - sanitize_tool_result(result) - return result - - 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" - - module, error = _load_task_trace_module() - if module is None: - return {"ok": False, "reason": error or "cannot load trace module"} + 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() - 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.sanitizer_result: Dict[str, Any] = {} - self.trace_result: Dict[str, Any] = {} - - def _sync_runtime(self, api: Any) -> Dict[str, Any]: - self.last_apply_result = apply_teamharness() - 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, - "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 91993903d..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,7 +113,11 @@ 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.1", + "max" => "2.1.0" + }, "meta" => { "category" => "teamharness", "features" => [ diff --git a/plugins/teamharness/mcp/server.py b/plugins/teamharness/mcp/server.py index e18ea2673..7bdc72c17 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 "" @@ -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/adapters/qwenpaw/test_adapter.py b/plugins/tests/teamharness/adapters/qwenpaw/test_adapter.py index 47177d588..2b01c4d92 100644 --- a/plugins/tests/teamharness/adapters/qwenpaw/test_adapter.py +++ b/plugins/tests/teamharness/adapters/qwenpaw/test_adapter.py @@ -1,508 +1,109 @@ +"""QwenPaw 2 public plugin API contract tests for TeamHarness.""" + +import asyncio 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 _clear_agent_workspace_env(): - original = os.environ.pop("AGENT_WORKSPACE", None) - try: - yield - finally: - 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") +class FakeApi: + def __init__(self): + self.calls = [] - 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 __getattr__(self, name): + def call(*args, **kwargs): + self.calls.append((name, args, kwargs)) + return call -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}" - ) +def test_register_uses_qwenpaw_2_public_extension_points(monkeypatch): + module = load_plugin() + class Router: + def get(self, _path): + return lambda function: function - assert raw_secret not in text - assert "access_key_secret=********" in text - assert "LTAI****" in text - assert "token=********" in text + def post(self, _path): + return lambda function: function - -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", + monkeypatch.setitem( + sys.modules, + "fastapi", + types.SimpleNamespace(APIRouter=Router), ) - - monkeypatch.setenv("QWENPAW_WORKING_DIR", str(qwenpaw_dir)) - monkeypatch.setenv("QWENPAW_WORKSPACE_DIR", str(workspace_dir)) - - result = module.apply_credential_guard() - - 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_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_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_output_sanitizer_wrapper", - lambda api=None: calls.append(("sanitizer", api)) or {"ok": True, "installed": True}, + "_register_trace_hooks", + lambda api: api.calls.append(("trace", (), {})), ) + 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_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, + ) + ] - 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} - assert calls == [("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 + assert asyncio.run(collect()) == [{"text": "ok"}] - 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") +def test_team_prompt_reads_packaged_contract(): + module = load_plugin() + assert "TeamHarness" in module._team_prompt(None) - 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" +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 be1dc3385..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 @@ -78,30 +79,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.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/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" 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/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 45df9c2ad..000000000 --- a/plugins/tests/workerflow/adapters/qwenpaw/test_adapter.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Tests for WorkerFlow QwenPaw adapter wiring.""" - -from __future__ import annotations - -import importlib.util -import os -from pathlib import Path -import unittest - - -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.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 - - 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") - - -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 ca1ff08d2..67abfc490 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.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 4cfa1370d..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,204 +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} - class WorkerFlowPlugin: - def __init__(self) -> None: - self.last_apply_result: dict[str, Any] = {} - def register(self, api: Any) -> None: - def sync() -> dict[str, Any]: - self.last_apply_result = apply_workerflow() - 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"} - - @router.get("/status") - def status() -> dict[str, Any]: - return {"ok": True, "lastApply": self.last_apply_result} + return {"ok": True, "plugin": "workerflow", "adapter": "qwenpaw-2"} @router.post("/sync") def sync_endpoint() -> dict[str, Any]: - self.last_apply_result = apply_workerflow() - return self.last_apply_result - - api.register_http_router(router, prefix="/workerflow", tags=["workerflow"]) + return { + "ok": True, + "plugin": "workerflow", + "managedBy": "qwenpaw-plugin-api", + } + + 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 d407d11eb..76db1b803 100755 --- a/plugins/workerflow/adapters/qwenpaw/scripts/build-qwenpaw-plugin.rb +++ b/plugins/workerflow/adapters/qwenpaw/scripts/build-qwenpaw-plugin.rb @@ -105,7 +105,11 @@ 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.1", + "max" => "2.1.0" + }, "meta" => { "category" => "workerflow", "features" => [ diff --git a/qwenpaw/Dockerfile b/qwenpaw/Dockerfile index 7b1c7bdc0..6caa53c37 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.1 ARG NPM_REGISTRY=https://registry.npmjs.org/ RUN if [ -n "${APT_MIRROR}" ]; then \ @@ -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,18 +99,15 @@ 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 -# 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/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 diff --git a/qwenpaw/pyproject.toml b/qwenpaw/pyproject.toml index e6385ba57..f3716bb4a 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.1", + "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/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/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/qwenpaw_worker/api.py b/qwenpaw/src/qwenpaw_worker/api.py new file mode 100644 index 000000000..65b9a3ef4 --- /dev/null +++ b/qwenpaw/src/qwenpaw_worker/api.py @@ -0,0 +1,461 @@ +"""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 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, + 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 4f2d0d8fb..131d02aa6 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 @@ -349,6 +351,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 +378,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), @@ -438,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) ) @@ -901,12 +909,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): @@ -961,7 +968,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) @@ -969,8 +975,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( @@ -1109,29 +1113,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 {} @@ -1174,6 +1155,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) @@ -1191,6 +1185,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 @@ -1255,41 +1251,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 @@ -1297,234 +1258,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.""" @@ -1535,14 +1268,16 @@ 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, + runtime_reconcile: Optional[Callable[[MemberRuntimeConfig], None]] = 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.runtime_reconcile = runtime_reconcile self.package_manager = package_manager or AgentPackageManager( config.qwenpaw_working_dir / "agent-packages", workspace_dir=config.default_workspace_dir, @@ -1601,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) @@ -1609,13 +1346,15 @@ 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 if adapter_should_apply: 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 " @@ -1633,30 +1372,21 @@ def apply_once( ) return ApplyResult(runtime_config=config, changed=True, agent_package_dir=applied_package) - 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 _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 + 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 _adapter_neutral_change(self, config: MemberRuntimeConfig) -> bool: previous = self.current_config @@ -1783,25 +1513,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") @@ -1819,33 +1532,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("/") @@ -1855,15 +1549,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) @@ -1886,25 +1647,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"], @@ -1913,56 +1665,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")) @@ -1974,8 +1697,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: @@ -1998,8 +1722,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 @@ -2033,13 +1757,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( @@ -2206,55 +1928,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 91a800509..801bbf8c7 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.""" @@ -54,15 +52,28 @@ 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 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, + runtime_reconcile=self._reconcile_runtime_storage, ) self._process: Optional[asyncio.subprocess.Process] = None self._heartbeat_probe_task: Optional[asyncio.Task] = None @@ -70,6 +81,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(): @@ -141,11 +153,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)) @@ -161,54 +173,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: @@ -273,11 +247,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), ) @@ -319,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) @@ -368,6 +351,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 {} @@ -401,102 +401,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"): - 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"): @@ -513,12 +417,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") @@ -668,82 +576,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" @@ -792,6 +624,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) + 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( + self.updater.apply_once, + runtime_config, + True, + False, + ) + 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() @@ -817,6 +715,73 @@ 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_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()) + }, + "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.wait_for_mcp_tools(plugin_id) + 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..beb17eafb --- /dev/null +++ b/qwenpaw/tests/test_api.py @@ -0,0 +1,301 @@ +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 = {} + mcp_tools_unavailable = 0 + 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/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 + 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.mcp_tools_unavailable = 0 + _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_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) + + 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_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..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: @@ -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() @@ -64,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 @@ -95,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", @@ -155,11 +161,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, @@ -170,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 @@ -199,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 = {} @@ -904,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..14cdfbc57 --- /dev/null +++ b/qwenpaw/tests/test_matrix_plugin.py @@ -0,0 +1,40 @@ +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 + + +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 diff --git a/qwenpaw/tests/test_package_update.py b/qwenpaw/tests/test_package_update.py index 953469926..cc66cfbe3 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,50 @@ 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" + 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", + agent_role="worker", + ) + updater = RuntimeUpdater(config) + + 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" + 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): saved: dict[str, object] = {} agent_config = types.SimpleNamespace(mcp=None) @@ -263,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: @@ -320,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: @@ -379,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: @@ -428,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: @@ -461,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: @@ -541,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: @@ -566,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: @@ -962,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: @@ -984,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: @@ -1019,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_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..4bffa593b --- /dev/null +++ b/qwenpaw/tests/test_runtime_dependencies.py @@ -0,0 +1,42 @@ +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_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.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..0a7008262 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(), @@ -137,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, @@ -144,7 +349,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 +424,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 +482,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 +541,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 +588,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 +637,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 +667,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 +747,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 +795,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 +836,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 +858,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 +874,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 +942,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 +973,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 +1011,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 +1054,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 +1087,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 +1118,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 +1148,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 +1191,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 +1223,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 +1291,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 +1317,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 +1347,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 +1377,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 +1395,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 +1430,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 +1447,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 +1482,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 +1539,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 +1574,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 +1614,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 +1649,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 +1693,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 +1731,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 +1773,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 +1808,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 +1826,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 +1861,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 +1913,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 +1942,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 +1989,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 +2041,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 7cf03f5bd..85a8d1cac 100644 --- a/qwenpaw/tests/test_worker_lifecycle.py +++ b/qwenpaw/tests/test_worker_lifecycle.py @@ -150,7 +150,42 @@ 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_configure_qwenpaw_runtime_uses_workspace_teams_prompt( +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, ) -> None: @@ -210,7 +245,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" @@ -219,7 +254,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: @@ -286,7 +321,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: @@ -410,7 +445,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") @@ -514,6 +549,127 @@ 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, + monkeypatch: pytest.MonkeyPatch, +) -> None: + worker = Worker(_config(tmp_path)) + monkeypatch.setenv("AGENTTEAMS_STORAGE_PREFIX", "agentteams/agentteams-storage") + worker._prepare_env() + created = {} + policies = {} + events = [] + + 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 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() + worker._configure_builtin_plugin_mcp_clients() + worker._configure_builtin_plugin_mcp_policies() + + 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( + "/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() + + +@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, @@ -621,6 +777,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( @@ -648,9 +805,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() @@ -698,10 +852,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 @@ -709,13 +863,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() @@ -736,121 +893,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, @@ -862,10 +904,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() @@ -890,19 +929,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() @@ -922,10 +957,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() @@ -985,10 +1017,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() @@ -1053,7 +1082,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") @@ -1070,7 +1098,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: @@ -1081,9 +1109,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) @@ -1091,34 +1119,32 @@ 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, ) -> None: config = _config(tmp_path) _runtime_yaml(config.runtime_config_path) 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): - 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) - 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"] == "agent package failed" + assert await worker.start() is True + await worker.stop() def test_hot_update_apply_does_not_replace_qwenpaw_process(tmp_path: Path) -> None: @@ -1135,6 +1161,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, 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/check-installer-pre-1.2-env-compat.sh b/tests/check-installer-pre-1.2-env-compat.sh index 2ea0ba5e7..177c55d5e 100755 --- a/tests/check-installer-pre-1.2-env-compat.sh +++ b/tests/check-installer-pre-1.2-env-compat.sh @@ -87,6 +87,7 @@ for suffix in \ MANAGER_IMAGE \ WORKER_IMAGE \ COPAW_WORKER_IMAGE \ + QWENPAW_WORKER_IMAGE \ HERMES_WORKER_IMAGE \ MATRIX_DOMAIN \ MATRIX_URL \ diff --git a/tests/lib/test-helpers.sh b/tests/lib/test-helpers.sh index 771bb83a8..9ded23928 100755 --- a/tests/lib/test-helpers.sh +++ b/tests/lib/test-helpers.sh @@ -402,18 +402,108 @@ 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 +} + +# 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() { + 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_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 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-14-git-collab.sh b/tests/test-14-git-collab.sh index 3b335dcea..67ddf71ed 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') @@ -106,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)**: @@ -188,21 +196,124 @@ done assert_not_empty "${PROJECT_ROOM}" "Project room created by Manager" log_info "Project room: ${PROJECT_ROOM}" -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) +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 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..9845985e1 100644 --- a/tests/test-17-worker-config-verify.sh +++ b/tests/test-17-worker-config-verify.sh @@ -17,6 +17,21 @@ 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" +elif [ "${TEST_WORKER_RUNTIME}" = "qwenpaw" ]; then + BUILTIN_CONTENT_SENTINEL="Session files are runtime-private state" + 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" + BUILTIN_SKILLS=(file-sync task-progress mcporter find-skills project-participation) + FILE_SHARING_SKILL="file-sync" +fi _cleanup() { log_info "Cleaning up: ${TEST_WORKER}" @@ -107,27 +122,43 @@ 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 # ============================================================ 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" - -# Builtin content filled (not empty between markers) -assert_contains "${AGENTS_CONTENT}" "Every Session" "AGENTS.md builtin section has content (not empty)" +# 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 -# 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" +# 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 -# 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" @@ -145,30 +176,44 @@ 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 - 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-sync/SKILL.md" 2>/dev/null || echo "") -if echo "${FILESYNC_CONTENT}" | grep -q "coordinator"; then - log_pass "file-sync SKILL.md uses 'coordinator'" -else - log_fail "file-sync 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 # ============================================================ @@ -176,19 +221,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" @@ -290,9 +344,17 @@ 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 + 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 13a121329..cb38096f4 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}" @@ -161,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 # ============================================================ @@ -224,45 +236,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 +300,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 +316,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 +331,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 +395,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-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 + 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-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-21-team-project-dag.sh b/tests/test-21-team-project-dag.sh index b1e419376..26cb18e02 100755 --- a/tests/test-21-team-project-dag.sh +++ b/tests/test-21-team-project-dag.sh @@ -241,6 +241,20 @@ 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" \ + && 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" 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 +370,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 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 d104c3790..787b44d79 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" ] || [ "${TEST_WORKER_RUNTIME}" = "qwenpaw" ]; then + BASELINE_SKILL="file-sharing" +else + BASELINE_SKILL="file-sync" +fi _cleanup() { log_info "Cleaning up: ${TEST_WORKER}" @@ -83,11 +89,27 @@ 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/file-sync/SKILL.md"; then - log_pass "Built-in skill 'file-sync' present in MinIO" +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}") + 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 '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 diff --git a/tests/test-26-qwenpaw-teamharness-plugin-mode.sh b/tests/test-26-qwenpaw-teamharness-plugin-mode.sh index e49caa415..5824ba323 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, @@ -406,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" @@ -652,7 +699,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 +770,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 +783,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 +811,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 # ============================================================ @@ -846,7 +907,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" @@ -911,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 @@ -929,22 +990,27 @@ 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 '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 @@ -974,34 +1040,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 } @@ -1015,36 +1080,55 @@ _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"] +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"] + +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") -for rel in ["mcporter-servers.json", "config/mcporter.json"]: - path = workspace / rel - if not path.is_file(): - problems.append(f"missing:{rel}") - continue - 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}") - authorization = (server.get("headers") or {}).get("Authorization", "") - if not authorization.startswith("Bearer "): - problems.append(f"auth:{rel}") +legacy_path = workspace / "mcporter-servers.json" +if legacy_path.exists(): + problems.append("legacy:mcporter-servers.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") != api_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 @@ -1058,17 +1142,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 @@ -1250,7 +1332,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" 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 @@ -1315,7 +1401,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 @@ -1336,13 +1422,12 @@ 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 --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