From 9624e8e16f9fd61a1411cbd3ea3de3d4f8eb10e9 Mon Sep 17 00:00:00 2001 From: Bill Date: Mon, 9 Feb 2026 19:33:42 -0700 Subject: [PATCH] Step 157: add multi-agent roles and provenance --- PROGRESS.md | 1 + editor/CMakeLists.txt | 3 + editor/src/AgentPermissionPolicy.h | 55 +++++++++++++++++ editor/src/EditorState.h | 95 +++++++++++++++++++++++++++--- editor/src/IncrementalOptimizer.h | 24 ++++++++ editor/src/main.cpp | 34 +++++++++-- editor/tests/step157_test.cpp | 61 +++++++++++++++++++ sprint5_plan.md | 2 +- 8 files changed, 259 insertions(+), 16 deletions(-) create mode 100644 editor/src/AgentPermissionPolicy.h create mode 100644 editor/tests/step157_test.cpp diff --git a/PROGRESS.md b/PROGRESS.md index f183e65..a78b387 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -504,3 +504,4 @@ Sprint 5 in progress. Step 141 (Emacs daemon with user config) done. Next: Step | 2026-02-10 | Codex | Step 154: Agent library context payload sent on connect with primitives snapshot. 4/4 tests pass. | | 2026-02-10 | Codex | Step 155: Agent library-aware code generation RPC + generator helper. 3/3 tests pass. | | 2026-02-10 | Codex | Step 156: Agent annotation assistant RPC with suggestions/diagnostics, feedback learning, and mutation apply helpers. 5/5 tests pass. | +| 2026-02-10 | Codex | Step 157: Multi-agent roles with permission policy, agent provenance in transform history, and UI role controls. 8/8 tests pass. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 684321f..273e358 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -883,6 +883,9 @@ target_link_libraries(step155_test PRIVATE nlohmann_json::nlohmann_json) add_executable(step156_test tests/step156_test.cpp) target_include_directories(step156_test PRIVATE src) +add_executable(step157_test tests/step157_test.cpp) +target_include_directories(step157_test PRIVATE src) + find_package(SDL2 CONFIG REQUIRED) find_package(OpenGL REQUIRED) find_package(glad CONFIG REQUIRED) diff --git a/editor/src/AgentPermissionPolicy.h b/editor/src/AgentPermissionPolicy.h new file mode 100644 index 0000000..a679829 --- /dev/null +++ b/editor/src/AgentPermissionPolicy.h @@ -0,0 +1,55 @@ +#pragma once +// Step 157: Agent role permissions + +#include +#include + +enum class AgentRole { + Linter = 0, + Refactor = 1, + Generator = 2 +}; + +struct AgentPermissionPolicy { + static const char* roleLabel(AgentRole role) { + switch (role) { + case AgentRole::Linter: return "Linter"; + case AgentRole::Refactor: return "Refactor"; + case AgentRole::Generator: return "Generator"; + } + return "Linter"; + } + + static AgentRole roleFromString(const std::string& role) { + std::string lower = role; + std::transform(lower.begin(), lower.end(), lower.begin(), + [](unsigned char c) { return (char)std::tolower(c); }); + if (lower == "refactor") return AgentRole::Refactor; + if (lower == "generator") return AgentRole::Generator; + return AgentRole::Linter; + } + + static bool canInvoke(AgentRole role, const std::string& method) { + if (method == "getAST" || + method == "getAnnotationSuggestions" || + method == "recordAnnotationFeedback" || + method == "setAgentRole") { + return true; + } + + if (method == "generateCode") { + return role == AgentRole::Refactor || role == AgentRole::Generator; + } + + if (method == "applyMutation" || + method == "applyAnnotationSuggestion") { + return role == AgentRole::Refactor || role == AgentRole::Generator; + } + + return true; + } + + static bool canMutate(AgentRole role) { + return role == AgentRole::Refactor || role == AgentRole::Generator; + } +}; diff --git a/editor/src/EditorState.h b/editor/src/EditorState.h index 2b46d47..3f0f307 100644 --- a/editor/src/EditorState.h +++ b/editor/src/EditorState.h @@ -51,6 +51,7 @@ #include "AgentLibraryPolicy.h" #include "AgentCodeGen.h" #include "AgentAnnotationAssistant.h" +#include "AgentPermissionPolicy.h" #include "CompositionPanel.h" #include "IncrementalOptimizer.h" #include "EmacsIntegration.h" @@ -174,7 +175,7 @@ struct EditorState { MockWebSocketTransport* agentTransport = nullptr; int agentPort = 8765; std::vector agentLog; - std::map agentMutationPermissions; + std::map agentRoles; BuildSystem::Type buildType = BuildSystem::Type::None; std::vector buildErrors; std::string lastBuildOutput; @@ -910,9 +911,9 @@ struct EditorState { const std::string& event) { logAgentEvent("Agent " + s.sessionId + " " + event); if (event == "connected") { - agentMutationPermissions[s.sessionId] = false; + agentRoles[s.sessionId] = AgentRole::Linter; } else if (event == "disconnected") { - agentMutationPermissions.erase(s.sessionId); + agentRoles.erase(s.sessionId); } }); agentServer->setRequestLogCallback([this](const std::string& sid, @@ -937,6 +938,27 @@ struct EditorState { agentLog.push_back(msg); } + AgentRole getAgentRole(const std::string& sessionId) const { + auto it = agentRoles.find(sessionId); + return it != agentRoles.end() ? it->second : AgentRole::Linter; + } + + void setAgentRole(const std::string& sessionId, AgentRole role) { + agentRoles[sessionId] = role; + } + + std::string agentActorLabel(const std::string& sessionId) const { + std::string label = "agent:" + sessionId; + if (agentServer) { + if (const auto* sess = agentServer->getSession(sessionId)) { + if (!sess->agentName.empty()) { + label = "agent:" + sess->agentName; + } + } + } + return label; + } + void refreshBuildSystem() { buildType = BuildSystem::detect(workspaceRoot); } @@ -1108,6 +1130,7 @@ struct EditorState { response["id"] = request.contains("id") ? request["id"] : json(nullptr); std::string method = request.value("method", ""); + AgentRole role = getAgentRole(sessionId); if (method == "getAST") { if (!active() || !isStructured()) { response["error"] = {{"code", -32000}, {"message", "No structured buffer"}}; @@ -1127,6 +1150,10 @@ struct EditorState { } if (method == "generateCode") { + if (!AgentPermissionPolicy::canInvoke(role, method)) { + response["error"] = {{"code", -32031}, {"message", "Role not permitted"}}; + return response; + } if (!active() || !isStructured()) { response["error"] = {{"code", -32000}, {"message", "No structured buffer"}}; return response; @@ -1162,7 +1189,22 @@ struct EditorState { return response; } + if (method == "setAgentRole") { + auto params = request.contains("params") ? request["params"] : json::object(); + std::string roleName = params.value("role", "linter"); + AgentRole newRole = AgentPermissionPolicy::roleFromString(roleName); + setAgentRole(sessionId, newRole); + response["result"] = { + {"role", AgentPermissionPolicy::roleLabel(newRole)} + }; + return response; + } + if (method == "getAnnotationSuggestions") { + if (!AgentPermissionPolicy::canInvoke(role, method)) { + response["error"] = {{"code", -32031}, {"message", "Role not permitted"}}; + return response; + } if (!active() || !isStructured()) { response["error"] = {{"code", -32000}, {"message", "No structured buffer"}}; return response; @@ -1220,9 +1262,8 @@ struct EditorState { } if (method == "applyAnnotationSuggestion") { - auto permIt = agentMutationPermissions.find(sessionId); - if (permIt == agentMutationPermissions.end() || !permIt->second) { - response["error"] = {{"code", -32030}, {"message", "Mutation not permitted"}}; + if (!AgentPermissionPolicy::canInvoke(role, method)) { + response["error"] = {{"code", -32031}, {"message", "Role not permitted"}}; return response; } if (!active() || !isStructured()) { @@ -1256,6 +1297,18 @@ struct EditorState { } applyOrchestratorToActive(); + if (active()) { + std::vector affected; + if (!suggestion.nodeId.empty()) affected.push_back(suggestion.nodeId); + if (!suggestion.annotationType.empty() && !suggestion.nodeId.empty()) { + affected.push_back("anno_" + suggestion.annotationType + "_" + suggestion.nodeId); + } + active()->incrementalOptimizer.setRoot(active()->sync.getAST()); + active()->incrementalOptimizer.recordExternalTransform( + "agent-annotation", + affected, + agentActorLabel(sessionId)); + } response["result"] = { {"success", true}, {"warning", res.warning} @@ -1264,6 +1317,10 @@ struct EditorState { } if (method == "recordAnnotationFeedback") { + if (!AgentPermissionPolicy::canInvoke(role, method)) { + response["error"] = {{"code", -32031}, {"message", "Role not permitted"}}; + return response; + } auto params = request.contains("params") ? request["params"] : json::object(); MemoryStrategyInference::Suggestion suggestion; suggestion.nodeId = params.value("nodeId", ""); @@ -1280,9 +1337,8 @@ struct EditorState { } if (method == "applyMutation") { - auto permIt = agentMutationPermissions.find(sessionId); - if (permIt == agentMutationPermissions.end() || !permIt->second) { - response["error"] = {{"code", -32030}, {"message", "Mutation not permitted"}}; + if (!AgentPermissionPolicy::canInvoke(role, method)) { + response["error"] = {{"code", -32031}, {"message", "Role not permitted"}}; return response; } if (!active() || !isStructured()) { @@ -1302,8 +1358,11 @@ struct EditorState { mut.setRoot(ast); ASTMutationAPI::MutationResult res; LibraryPolicyResult policy; + std::vector affectedIds; if (type == "setProperty") { + std::string nodeId = params.value("nodeId", ""); + if (!nodeId.empty()) affectedIds.push_back(nodeId); res = mut.setProperty(params.value("nodeId", ""), params.value("property", ""), params.value("value", "")); @@ -1314,13 +1373,19 @@ struct EditorState { props[k] = v.get(); } } + std::string nodeId = params.value("nodeId", ""); + if (!nodeId.empty()) affectedIds.push_back(nodeId); res = mut.updateNode(params.value("nodeId", ""), props); } else if (type == "deleteNode") { + std::string nodeId = params.value("nodeId", ""); + if (!nodeId.empty()) affectedIds.push_back(nodeId); res = mut.deleteNode(params.value("nodeId", "")); } else if (type == "insertNode") { ASTNode* node = nullptr; + std::string insertedId; if (params.contains("node")) { node = fromJson(params["node"]); + if (node) insertedId = node->id; } if (node) { policy = checkMutationLibraryPolicy(node, ast, preferImports, strictMode); @@ -1334,6 +1399,8 @@ struct EditorState { params.value("role", ""), node); if (!res.success && node) { deleteTree(node); + } else if (res.success && !insertedId.empty()) { + affectedIds.push_back(insertedId); } } else { response["error"] = {{"code", -32602}, {"message", "Unknown mutation type"}}; @@ -1346,6 +1413,16 @@ struct EditorState { } applyOrchestratorToActive(); + if (active()) { + if (affectedIds.empty() && !params.value("nodeId", "").empty()) { + affectedIds.push_back(params.value("nodeId", "")); + } + active()->incrementalOptimizer.setRoot(active()->sync.getAST()); + active()->incrementalOptimizer.recordExternalTransform( + "agent-mutation:" + type, + affectedIds, + agentActorLabel(sessionId)); + } response["result"] = { {"success", true}, {"warning", res.warning}, diff --git a/editor/src/IncrementalOptimizer.h b/editor/src/IncrementalOptimizer.h index 7a323d9..3fc9c94 100644 --- a/editor/src/IncrementalOptimizer.h +++ b/editor/src/IncrementalOptimizer.h @@ -18,6 +18,8 @@ public: std::string transformName; std::vector affectedNodeIds; std::string timestamp; + std::string actor; + bool reversible = true; }; void setRoot(ASTNode* root) { root_ = root; } @@ -29,6 +31,7 @@ public: record.info.transformId = tid; record.info.transformName = transformName; record.info.timestamp = currentTimestamp(); + record.info.actor = "optimizer"; if (transformName == "constant-fold") { applyConstantFolding(root_, tid, record); @@ -48,9 +51,30 @@ public: return result; } + std::string recordExternalTransform(const std::string& transformName, + const std::vector& affectedNodeIds, + const std::string& actor) { + std::string tid = "t" + std::to_string(nextId_++); + InternalRecord record; + record.info.transformId = tid; + record.info.transformName = transformName; + record.info.timestamp = currentTimestamp(); + record.info.actor = actor; + record.info.reversible = false; + record.info.affectedNodeIds = affectedNodeIds; + + for (const auto& nodeId : affectedNodeIds) { + provenance_[nodeId] = tid; + } + + history_.push_back(std::move(record)); + return tid; + } + bool undoTransform(const std::string& transformId) { for (auto it = history_.begin(); it != history_.end(); ++it) { if (it->info.transformId == transformId) { + if (!it->info.reversible) return false; // Apply undo actions in reverse order for (auto ait = it->undoActions.rbegin(); ait != it->undoActions.rend(); ++ait) { ait->undo(); diff --git a/editor/src/main.cpp b/editor/src/main.cpp index e65d649..ccca30a 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -1901,10 +1901,12 @@ int main(int, char**) { if (!s.agentName.empty()) label += " (" + s.agentName + ")"; ImGui::TextUnformatted(label.c_str()); ImGui::SameLine(260.0f); - bool canMutate = state.agentMutationPermissions[s.sessionId]; - std::string checkId = "Can mutate##" + s.sessionId; - if (ImGui::Checkbox(checkId.c_str(), &canMutate)) { - state.agentMutationPermissions[s.sessionId] = canMutate; + AgentRole role = state.getAgentRole(s.sessionId); + int roleIndex = static_cast(role); + const char* roleLabels[] = {"Linter", "Refactor", "Generator"}; + std::string comboId = "Role##" + s.sessionId; + if (ImGui::Combo(comboId.c_str(), &roleIndex, roleLabels, 3)) { + state.setAgentRole(s.sessionId, static_cast(roleIndex)); } ImGui::SameLine(420.0f); ImGui::Text("Msgs: %d", s.messageCount); @@ -2248,12 +2250,18 @@ int main(int, char**) { ImGui::TextColored(color, "%s", h.transformName.c_str()); ImGui::SameLine(); ImGui::TextDisabled("@ %s", h.timestamp.c_str()); + if (!h.actor.empty()) { + ImGui::SameLine(); + ImGui::TextDisabled("by %s", h.actor.c_str()); + } ImGui::SameLine(); + if (!h.reversible) ImGui::BeginDisabled(); if (ImGui::Button("Undo")) { if (inc.undoTransform(h.transformId)) { state.applyOrchestratorToActive(); } } + if (!h.reversible) ImGui::EndDisabled(); ImGui::Text("Affected: %d", (int)h.affectedNodeIds.size()); std::string nodeList; for (size_t i = 0; i < h.affectedNodeIds.size() && i < 5; ++i) { @@ -2385,10 +2393,12 @@ int main(int, char**) { ImGui::TextDisabled("(disabled in Text mode)"); } else if (ast) { std::map transformNames; + std::map transformActors; if (state.active()) { auto history = state.active()->incrementalOptimizer.getTransformHistory(); for (const auto& h : history) { transformNames[h.transformId] = h.transformName; + transformActors[h.transformId] = h.actor; } } // Show basic AST info @@ -2412,7 +2422,13 @@ int main(int, char**) { ImGui::TextColored(fnColor, " Function: %s", fn->name.c_str()); if (!tname.empty() && ImGui::IsItemHovered()) { ImGui::BeginTooltip(); - ImGui::Text("Transform: %s (%s)", tname.c_str(), tid.c_str()); + std::string actor = transformActors[tid]; + if (actor.empty()) { + ImGui::Text("Transform: %s (%s)", tname.c_str(), tid.c_str()); + } else { + ImGui::Text("Transform: %s (%s) by %s", + tname.c_str(), tid.c_str(), actor.c_str()); + } ImGui::EndTooltip(); } auto params = fn->getChildren("parameters"); @@ -2431,7 +2447,13 @@ int main(int, char**) { ImGui::TextColored(pColor, " param: %s", param->name.c_str()); if (!pname.empty() && ImGui::IsItemHovered()) { ImGui::BeginTooltip(); - ImGui::Text("Transform: %s (%s)", pname.c_str(), pid.c_str()); + std::string actor = transformActors[pid]; + if (actor.empty()) { + ImGui::Text("Transform: %s (%s)", pname.c_str(), pid.c_str()); + } else { + ImGui::Text("Transform: %s (%s) by %s", + pname.c_str(), pid.c_str(), actor.c_str()); + } ImGui::EndTooltip(); } } diff --git a/editor/tests/step157_test.cpp b/editor/tests/step157_test.cpp new file mode 100644 index 0000000..ccfc8f0 --- /dev/null +++ b/editor/tests/step157_test.cpp @@ -0,0 +1,61 @@ +// Step 157 TDD Test: Agent roles + transform provenance +#include "AgentPermissionPolicy.h" +#include "IncrementalOptimizer.h" +#include "ast/Module.h" +#include "ast/Function.h" +#include + +static void expect(bool cond, const std::string& name, int& passed, int& failed) { + if (cond) { + std::cout << "Test " << (passed + failed + 1) << " PASS: " << name << "\n"; + ++passed; + } else { + std::cout << "Test " << (passed + failed + 1) << " FAIL: " << name << "\n"; + ++failed; + } +} + +int main() { + int passed = 0; + int failed = 0; + + expect(!AgentPermissionPolicy::canInvoke(AgentRole::Linter, "applyMutation"), + "linter cannot mutate", passed, failed); + expect(AgentPermissionPolicy::canInvoke(AgentRole::Refactor, "applyMutation"), + "refactor can mutate", passed, failed); + expect(AgentPermissionPolicy::canInvoke(AgentRole::Generator, "generateCode"), + "generator can generate code", passed, failed); + + Module mod; + mod.id = "mod1"; + mod.name = "Test"; + mod.targetLanguage = "cpp"; + auto* fn = new Function(); + fn->id = "fn1"; + fn->name = "work"; + mod.addChild("functions", fn); + + IncrementalOptimizer inc; + inc.setRoot(&mod); + std::string tid = inc.recordExternalTransform( + "agent-mutation:setProperty", + {fn->id}, + "agent:test"); + + auto history = inc.getTransformHistory(); + bool found = false; + for (const auto& h : history) { + if (h.transformId == tid) { + found = true; + expect(h.actor == "agent:test", "actor recorded", passed, failed); + expect(!h.reversible, "external transform not reversible", passed, failed); + } + } + expect(found, "external transform in history", passed, failed); + expect(inc.getProvenance(fn->id) == tid, "provenance recorded", passed, failed); + expect(!inc.undoTransform(tid), "external transform cannot undo", passed, failed); + + std::cout << "\n=== Step 157 Results: " << passed << " passed, " + << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +} diff --git a/sprint5_plan.md b/sprint5_plan.md index ea4cadb..138cedb 100644 --- a/sprint5_plan.md +++ b/sprint5_plan.md @@ -313,7 +313,7 @@ primitives, and assist with constructive coding. to improve future suggestions. *Modifies:* `WebSocketServer.h`, `MemoryStrategyInference.h` -- [ ] **Step 157: Multi-agent collaboration** +- [x] **Step 157: Multi-agent collaboration** Support multiple agents connected simultaneously with different roles: - "Linter" agent: reads AST, reports issues, doesn't mutate - "Refactor" agent: can mutate with user approval