Pārlūkot izejas kodu

fix: synchronize EventPrompt with tool mode

Problem: EventPrompt could remain stale, duplicate after restore, or be sent in chat_agent_tools mode.

Risk: legacy generated prompts are matched conservatively by established protocol markers.
zhenyu.hu 2 nedēļas atpakaļ
vecāks
revīzija
cf481ab2a6

+ 59 - 15
src/agent_lab/presentation/static/app.js

@@ -25,6 +25,7 @@ const eventConfigDialog = document.querySelector("#event-config-dialog");
 const auditDialog = document.querySelector("#audit-dialog");
 const toolInvocationMode = document.querySelector("#tool-invocation-mode");
 const toolModeHelp = document.querySelector("#tool-mode-help");
+const eventAgentModelSettings = document.querySelector("#event-agent-model-settings");
 const WORKSPACE_SNAPSHOTS_STORAGE_KEY = "agent-lab.workspace-snapshots.v1";
 const LEGACY_PROMPT_SETS_STORAGE_KEY = "agent-lab.prompt-sets.v1";
 
@@ -82,7 +83,7 @@ bindClick("#select-all-tools", () => {
 bindClick("#clear-tools", () => {
   setAllTools(false);
 });
-toolInvocationMode.addEventListener("change", updateToolModeHelp);
+toolInvocationMode.addEventListener("change", refreshToolModeState);
 
 chatForm.addEventListener("submit", (event) => {
   event.preventDefault();
@@ -90,7 +91,6 @@ chatForm.addEventListener("submit", (event) => {
 });
 
 initializePromptList();
-updateToolModeHelp();
 refreshWorkspaceSnapshotSelector();
 loadTools();
 loadSessions({ preserveStatus: true });
@@ -261,7 +261,7 @@ function renderTools(tools) {
   if (!tools.length) {
     toolList.textContent = "No tools available";
     updateToolCount();
-    updateEventPromptItem(tools);
+    refreshToolModeState();
     return;
   }
 
@@ -280,7 +280,7 @@ function renderTools(tools) {
     checkbox.checked = true;
     checkbox.addEventListener("change", () => {
       updateToolCount();
-      updateEventPromptItem(availableTools);
+      refreshToolModeState();
     });
 
     label.append(checkbox, ` ${tool.name}`);
@@ -302,7 +302,7 @@ function renderTools(tools) {
     pendingEnabledTools = null;
   }
   updateToolCount();
-  updateEventPromptItem(tools);
+  refreshToolModeState();
 }
 
 function formatRequiredParameters(tool) {
@@ -317,7 +317,7 @@ function setAllTools(checked) {
     checkbox.checked = checked;
   });
   updateToolCount();
-  updateEventPromptItem(availableTools);
+  refreshToolModeState();
 }
 
 function updateToolCount() {
@@ -435,8 +435,21 @@ function restoreWorkspaceSnapshot(snapshot) {
     eventAgent.batch_timeout_seconds ?? 15;
   const enabledTools = eventAgent.enabled_tools || [];
   pendingEnabledTools = applySelectedTools(enabledTools) ? null : enabledTools;
-  updateEventPromptItem(availableTools);
+  refreshToolModeState();
+}
+
+function refreshToolModeState() {
+  const isChatAgentTools = toolInvocationMode.value === "chat_agent_tools";
   updateToolModeHelp();
+  eventAgentModelSettings
+    .querySelectorAll("input, textarea, select, button")
+    .forEach((control) => {
+      control.disabled = isChatAgentTools;
+    });
+  removeGeneratedEventPromptItems();
+  if (!isChatAgentTools) {
+    updateEventPromptItem(availableTools);
+  }
 }
 
 function updateToolModeHelp() {
@@ -480,7 +493,6 @@ function restoreSystemPrompts(prompts) {
   promptItems.forEach((prompt) => {
     systemPrompts.append(createSystemPrompt(prompt));
   });
-  ensureEventPromptItem();
 }
 
 function restorePreMessages(messages) {
@@ -573,7 +585,7 @@ function applySelectedTools(toolNames) {
     checkbox.checked = selected.has(checkbox.value);
   });
   updateToolCount();
-  updateEventPromptItem(availableTools);
+  refreshToolModeState();
   return true;
 }
 
@@ -582,7 +594,7 @@ function initializePromptList() {
     bindPromptItemControls(item);
     bindPromptDrag(item);
   });
-  ensureEventPromptItem();
+  refreshToolModeState();
 }
 
 function buildPromptItems() {
@@ -600,7 +612,14 @@ function buildCustomPromptItems() {
 }
 
 function collectSystemPrompts() {
-  return buildPromptItems()
+  const promptItems = buildPromptItems();
+  const serializablePrompts = toolInvocationMode.value === "chat_agent_tools"
+    ? promptItems.filter((item) => (
+      item.prompt_kind !== "event_agent_system"
+      && !isGeneratedEventPromptContent(item.content)
+    ))
+    : promptItems;
+  return serializablePrompts
     .map((item) => item.content)
     .filter(Boolean);
 }
@@ -609,10 +628,11 @@ function normalizePromptItems(prompts) {
   const items = Array.isArray(prompts) ? prompts : [];
   const normalized = items
     .map((prompt) => normalizePromptItem(prompt))
-    .filter((prompt) => prompt.prompt_kind === "event_agent_system" || prompt.content);
-  if (!normalized.some((prompt) => prompt.prompt_kind === "event_agent_system")) {
-    normalized.push({ prompt_kind: "event_agent_system", content: "" });
-  }
+    .filter((prompt) => (
+      prompt.prompt_kind !== "event_agent_system"
+      && prompt.content
+      && !isGeneratedEventPromptContent(prompt.content)
+    ));
   if (!normalized.some((prompt) => prompt.prompt_kind === "custom")) {
     normalized.unshift({ prompt_kind: "custom", role: "system", content: "" });
   }
@@ -636,6 +656,18 @@ function normalizePromptItem(prompt) {
   };
 }
 
+function isGeneratedEventPromptContent(content) {
+  const normalized = typeof content === "string" ? content.trim() : "";
+  return normalized.startsWith(
+    "You may request EventAgent work using this text protocol.",
+  )
+    && normalized.includes("<agent_events>")
+    && normalized.includes("</agent_events>")
+    && normalized.includes(
+      "Use exact event names only, one per line. Do not include parameters.",
+    );
+}
+
 function buildAvailableEventsPrompt(tools) {
   const eventLines = tools.map((tool) => `- ${tool.name}: ${tool.description || "No description"}`);
   if (!eventLines.length) {
@@ -662,6 +694,18 @@ function updateEventPromptItem(tools) {
   eventPrompt.querySelector(".system-prompt").value = prompt;
 }
 
+function removeGeneratedEventPromptItems() {
+  systemPrompts.querySelectorAll(".prompt-item").forEach((item) => {
+    const content = item.querySelector(".system-prompt").value;
+    if (
+      item.dataset.promptKind === "event_agent_system"
+      || isGeneratedEventPromptContent(content)
+    ) {
+      item.remove();
+    }
+  });
+}
+
 function ensureEventPromptItem() {
   const existing = systemPrompts.querySelector('[data-prompt-kind="event_agent_system"]');
   if (existing) {

+ 12 - 10
src/agent_lab/presentation/static/index.html

@@ -216,19 +216,21 @@
           <button id="close-event-config" type="button">Close</button>
         </div>
         <section>
-          <label>Model <input id="event-model" placeholder="Same as ChatAgent" /></label>
-          <label>Temperature <input id="event-temperature" type="number" min="0" max="2" step="0.1" value="0.2" /></label>
-          <label>Max Tokens <input id="event-max-tokens" type="number" min="1" step="1" value="800" /></label>
-          <label>System Prompt <textarea id="event-system-prompt" rows="5" placeholder="Optional EventAgent system prompt"></textarea></label>
+          <div id="event-agent-model-settings">
+            <label>Model <input id="event-model" placeholder="Same as ChatAgent" /></label>
+            <label>Temperature <input id="event-temperature" type="number" min="0" max="2" step="0.1" value="0.2" /></label>
+            <label>Max Tokens <input id="event-max-tokens" type="number" min="1" step="1" value="800" /></label>
+            <label>System Prompt <textarea id="event-system-prompt" rows="5" placeholder="Optional EventAgent system prompt"></textarea></label>
+            <fieldset>
+              <legend>Extra Body</legend>
+              <label class="checkbox"><input id="event-thinking-disabled" type="checkbox" checked /> Disable thinking</label>
+              <label class="checkbox"><input id="event-enable-search" type="checkbox" /> Enable search</label>
+              <label class="checkbox"><input id="event-forced-search" type="checkbox" /> Forced search</label>
+            </fieldset>
+          </div>
           <label>Max Loops <input id="max-event-loops" type="number" min="1" step="1" value="1" /></label>
           <label>Max Parallel <input id="max-parallel-events" type="number" min="1" max="16" step="1" value="4" /></label>
           <label>Batch Timeout (seconds) <input id="batch-timeout-seconds" type="number" min="0.1" max="120" step="0.1" value="15" /></label>
-          <fieldset>
-            <legend>Extra Body</legend>
-            <label class="checkbox"><input id="event-thinking-disabled" type="checkbox" checked /> Disable thinking</label>
-            <label class="checkbox"><input id="event-enable-search" type="checkbox" /> Enable search</label>
-            <label class="checkbox"><input id="event-forced-search" type="checkbox" /> Forced search</label>
-          </fieldset>
         </section>
       </form>
     </dialog>

+ 213 - 1
tests/test_websocket_api.py

@@ -1393,6 +1393,218 @@ def test_static_chat_agent_prompt_is_a_single_sortable_list_with_event_instructi
     assert "promptDialog" not in js
 
 
+def test_static_event_prompt_refresh_uses_unified_tool_mode_state_path():
+    js = Path("src/agent_lab/presentation/static/app.js").read_text()
+    render_tools = js[
+        js.index("function renderTools(") :
+        js.index("function formatRequiredParameters(")
+    ]
+    set_all_tools = js[
+        js.index("function setAllTools(") :
+        js.index("function updateToolCount(")
+    ]
+    restore_snapshot = js[
+        js.index("function restoreWorkspaceSnapshot(") :
+        js.index("function updateToolModeHelp(")
+    ]
+    apply_selected_tools = js[
+        js.index("function applySelectedTools(") :
+        js.index("function initializePromptList(")
+    ]
+    initialize_prompt_list = js[
+        js.index("function initializePromptList(") :
+        js.index("function buildPromptItems(")
+    ]
+    refresh_tool_mode = js[
+        js.index("function refreshToolModeState(") :
+        js.index("function updateToolModeHelp(")
+    ]
+    remove_generated_prompts = js[
+        js.index("function removeGeneratedEventPromptItems(") :
+        js.index("function ensureEventPromptItem(")
+    ]
+
+    assert (
+        'toolInvocationMode.addEventListener("change", refreshToolModeState);'
+        in js
+    )
+    assert render_tools.count("refreshToolModeState();") == 3
+    assert "updateEventPromptItem(" not in render_tools
+    assert "refreshToolModeState();" in set_all_tools
+    assert "updateEventPromptItem(" not in set_all_tools
+    assert "refreshToolModeState();" in restore_snapshot
+    assert "refreshToolModeState();" in apply_selected_tools
+    assert "refreshToolModeState();" in initialize_prompt_list
+    assert js.count("updateEventPromptItem(") == 2
+    assert "removeGeneratedEventPromptItems();" in refresh_tool_mode
+    assert "if (!isChatAgentTools)" in refresh_tool_mode
+    assert "updateEventPromptItem(availableTools);" in refresh_tool_mode
+    assert 'querySelectorAll(".prompt-item")' in remove_generated_prompts
+    assert 'item.dataset.promptKind === "event_agent_system"' in remove_generated_prompts
+    assert "isGeneratedEventPromptContent(content)" in remove_generated_prompts
+    assert "item.remove();" in remove_generated_prompts
+    assert 'textarea.readOnly = promptItem.prompt_kind === "event_agent_system"' in js
+
+
+def test_static_prompt_restore_discards_generated_event_prompt_derived_state():
+    js = Path("src/agent_lab/presentation/static/app.js").read_text()
+    ordinary = "Keep this custom prompt with an <agent_events> example."
+    exact_name_docs = (
+        "Use exact event names only, one per line. Do not include parameters."
+    )
+
+    assert "function isGeneratedEventPromptContent(" in js
+    normalization = js[
+        js.index("function normalizePromptItems(") :
+        js.index("function buildAvailableEventsPrompt(")
+    ]
+    result = _run_node_json(
+        "\n".join(
+            [
+                normalization,
+                """
+const generated = [
+  "You may request EventAgent work using this text protocol.",
+  "Available events:",
+  "- handoff_note: Handoff a note",
+  "First write the user-facing reply normally. If events are needed, append this block after the visible reply:",
+  "<agent_events>",
+  "handoff_note",
+  "</agent_events>",
+  "Use exact event names only, one per line. Do not include parameters.",
+].join("\\n");
+const ordinary = "Keep this custom prompt with an <agent_events> example.";
+const exactNameDocs = "Use exact event names only, one per line. Do not include parameters.";
+console.log(JSON.stringify({
+  matches: [
+    isGeneratedEventPromptContent(generated),
+    isGeneratedEventPromptContent(ordinary),
+    isGeneratedEventPromptContent(exactNameDocs),
+  ],
+  normalized: normalizePromptItems([
+    { prompt_kind: "event_agent_system", role: "system", content: generated },
+    generated,
+    ordinary,
+    { prompt_kind: "custom", role: "system", content: exactNameDocs },
+  ]),
+  empty: normalizePromptItems([]),
+}));
+""",
+            ]
+        )
+    )
+
+    assert result["matches"] == [True, False, False]
+    assert result["normalized"] == [
+        {"prompt_kind": "custom", "role": "system", "content": ordinary},
+        {"prompt_kind": "custom", "role": "system", "content": exact_name_docs},
+    ]
+    assert result["empty"] == [
+        {"prompt_kind": "custom", "role": "system", "content": ""}
+    ]
+
+
+def test_static_event_prompt_is_excluded_from_chat_agent_tools_payload():
+    js = Path("src/agent_lab/presentation/static/app.js").read_text()
+    generated = "\n".join(
+        [
+            "You may request EventAgent work using this text protocol.",
+            "Available events:",
+            "- handoff_note: Handoff a note",
+            "<agent_events>",
+            "handoff_note",
+            "</agent_events>",
+            "Use exact event names only, one per line. Do not include parameters.",
+        ]
+    )
+    prompt_collection = js[
+        js.index("function buildPromptItems(") :
+        js.index("function normalizePromptItems(")
+    ]
+    generated_matcher = js[
+        js.index("function isGeneratedEventPromptContent(") :
+        js.index("function buildAvailableEventsPrompt(")
+    ]
+    result = _run_node_json(
+        "\n".join(
+            [
+                prompt_collection,
+                generated_matcher,
+                """
+const generated = [
+  "You may request EventAgent work using this text protocol.",
+  "Available events:",
+  "- handoff_note: Handoff a note",
+  "<agent_events>",
+  "handoff_note",
+  "</agent_events>",
+  "Use exact event names only, one per line. Do not include parameters.",
+].join("\\n");
+function promptItem(promptKind, content) {
+  return {
+    dataset: { promptKind, promptRole: "system" },
+    querySelector() {
+      return { value: content };
+    },
+  };
+}
+const systemPrompts = {
+  querySelectorAll() {
+    return [
+      promptItem("custom", "ordinary custom"),
+      promptItem("event_agent_system", generated),
+      promptItem("custom", generated),
+    ];
+  },
+};
+const toolInvocationMode = { value: "chat_agent_tools" };
+const chatAgentTools = collectSystemPrompts();
+toolInvocationMode.value = "dual_agent";
+const dualAgent = collectSystemPrompts();
+console.log(JSON.stringify({ chatAgentTools, dualAgent }));
+""",
+            ]
+        )
+    )
+
+    assert result["chatAgentTools"] == ["ordinary custom"]
+    assert result["dualAgent"] == ["ordinary custom", generated, generated]
+
+
+def test_static_tool_mode_disables_only_event_agent_model_settings_group():
+    html = Path("src/agent_lab/presentation/static/index.html").read_text()
+    js = Path("src/agent_lab/presentation/static/app.js").read_text()
+    model_settings_start = html.index('id="event-agent-model-settings"')
+    shared_settings_start = html.index('id="max-event-loops"')
+    model_settings_html = html[model_settings_start:shared_settings_start]
+    refresh_tool_mode = js[
+        js.index("function refreshToolModeState(") :
+        js.index("function updateToolModeHelp(")
+    ]
+
+    for control_id in [
+        "event-model",
+        "event-temperature",
+        "event-max-tokens",
+        "event-system-prompt",
+        "event-thinking-disabled",
+        "event-enable-search",
+        "event-forced-search",
+    ]:
+        assert f'id="{control_id}"' in model_settings_html
+
+    for control_id in [
+        "max-event-loops",
+        "max-parallel-events",
+        "batch-timeout-seconds",
+    ]:
+        assert f'id="{control_id}"' not in model_settings_html
+
+    assert 'document.querySelector("#event-agent-model-settings")' in js
+    assert '.querySelectorAll("input, textarea, select, button")' in refresh_tool_mode
+    assert "control.disabled = isChatAgentTools;" in refresh_tool_mode
+
+
 def test_static_app_script_is_versioned_and_click_binding_is_guarded():
     html = Path("src/agent_lab/presentation/static/index.html").read_text()
     js = Path("src/agent_lab/presentation/static/app.js").read_text()
@@ -1496,7 +1708,7 @@ def test_static_tool_mode_config_roundtrips_request_snapshot_and_session_restore
     assert 'snapshot.tool_invocation_mode || "dual_agent"' in restore_snapshot
     assert 'eventAgent.max_parallel_events ?? 4' in restore_snapshot
     assert 'eventAgent.batch_timeout_seconds ?? 15' in restore_snapshot
-    assert "updateToolModeHelp();" in restore_snapshot
+    assert "refreshToolModeState();" in restore_snapshot
     assert '`/api/sessions/${sessionId}`' in load_replay
     assert "const [session, messages, audit, usage] = await Promise.all([" in load_replay
     assert "restoreWorkspaceSnapshot(session.config || {});" in load_replay