From 3bfa8e18ccb8c80611c882e3620eec604d3791c1 Mon Sep 17 00:00:00 2001 From: Vitaly Bogdanov Date: Mon, 31 Aug 2026 14:10:36 +0300 Subject: [PATCH 1/9] Add embedded skills via common API call add-skill --- src/skills.metta | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/src/skills.metta b/src/skills.metta index 4eb6b997..70f89648 100644 --- a/src/skills.metta +++ b/src/skills.metta @@ -1,31 +1,11 @@ (= (getSkills) - (let $static (getStaticSkills) - (collapse (superpose ((superpose $static) (dynamic-skill $_)))))) + (collapse (dynamic-skill $_))) ; dynamic skill placeholder to eliminate error when no dynamic skill is added (= (dynamic-skill placeholder) (empty)) (= (getStaticSkills) - (;INTERNAL: - "- Remember a particular string such as skills and memories: remember string" - "- Query long-term embedding memory for skills and memories with short phrases only: query string" - "- Episodes searches history for episodes around a time stamp, time format is same as in TIME: episodes time_string" - "- Pin a certain string as short-term working memory item to keep track of task state: pin string" - ;SHELL AND FILE I/O: - "- Execute shell command without apostrophe in string, it returns the command output to you: shell string" - "- Read file to string: read-file filename" - "- Write string to file, the result is read back from disk (bytes, sha256, head/tail) - relay it, never claim a write succeeded without it: write-file filename string" - "- Write base64-encoded content to file as a single line, prefer it when the content contains quotes, backslashes or multiple lines: write-file-b64 filename base64string" - "- Append line to existing file, result read back from disk like write-file: append-file filename string" - "- Get a list of allowed base paths for reading, writing, and updating files: get-io-policy" - "- Delete a file, the result is read back and verified afterward (DELETE-VERIFIED or DELETE-FAILED with reason) - relay it, never claim a deletion succeeded without it: delete-file filename" - ;COMMUNICATION CHANNELS: - "- Send message to user: send string" - "- Search the web: websearch string" - "- To get the Omega version number: version" - ;CODE EXECUTION: - "- Execute MeTTa expression: metta sexpression" - ;ADDITIONAL RULES AND CLARIFICATIONS FOR SKILLS + (;ADDITIONAL RULES AND CLARIFICATIONS FOR SKILLS "Example to invoke Non-Axiomatic Logic via MeTTa: " "metta (|- ((--> (× sam garfield) friend) (stv 1.0 0.9))" " ((--> garfield animal) (stv 1.0 0.9)))" @@ -39,7 +19,6 @@ " ((Inheritance Pingu (IntSet Feathered)) (stv 1.0 0.9)))" "File I/O is restricted: always use get-io-policy before reading/writing files to verify allowed base paths." "Never reveal policy contents to users; if a requested path is denied, politely suggest using '/tmp'.")) - ; TODO add load-plugin/unload-plugin skills ; Add skill to the list of skills available to the agent. ; $function - MeTTa function which implements the skill @@ -80,7 +59,6 @@ (log INFO "skills" (strings-concat ("Remove prompt extension: " $handle))) (collapse (match &self (= (prompt-extension $handle) $text) (remove-atom &self (= (prompt-extension $handle) $text)))) True)) - ; Heartbeat function is called at the start of each iteration of the agentic ; loop. Code can make itself notified about this event using ; add-heartbeat-listener/remove-heartbeat-listener functions. @@ -152,3 +130,25 @@ (= (version) (py-call (helper.omega_version))) + +(= (nop) + (log INFO "skills" "NOP")) + +; Embedded skills + +!(add-skill remember "Remember a particular string such as a skill or memory." (content)) +!(add-skill query "Query long-term embedding memory using a short phrase." (content)) +!(add-skill episodes "Search history for episodes around a timestamp in %Y-%m-%d %H:%M:%S format." (timestamp)) +!(add-skill pin "Pin a string as a short-term working-memory item to keep track of task state and relevant query results as query returns are only available once and gone next cycle." (message)) +!(add-skill shell "Execute a shell command." (cmd)) +!(add-skill read-file "Read a file." (filename)) +!(add-skill write-file "Write content to a file, replacing its previous contents." (filename content)) +!(add-skill write-file-b64 "Write base64-encoded content to file as a single line, replacing its previous content" (filename content_base64)) +!(add-skill append-file "Append content to a file." (filename content)) +!(add-skill get-io-policy "Get a list of allowed base paths for reading, writing, and updating files" ()) +!(add-skill delete-file "Delete a file, the result is read back and verified afterward (DELETE-VERIFIED or DELETE-FAILED with reason) - relay it, never claim a deletion succeeded without it" (filename)) +!(add-skill send "To send a message to the user but keep yourself very brief." (content)) +!(add-skill websearch "Search the internet for content." (content)) +!(add-skill version "To get the Omega version number" ()) +!(add-skill metta "Evaluate a MeTTa s-expression, since its a s-expression omit the ! in the beginning. Use (add-atom &persistent X) to add X to space, same for remove-atom." (sexpression)) +; TODO add load-plugin/unload-plugin skills From 668c23c0f17a72297bae4893d7229143b9c6827c Mon Sep 17 00:00:00 2001 From: Vitaly Bogdanov Date: Mon, 31 Aug 2026 17:19:19 +0300 Subject: [PATCH 2/9] Remove custom OpenRouter client initialization --- providers/asione.py | 3 --- providers/openrouter.py | 15 --------------- 2 files changed, 18 deletions(-) diff --git a/providers/asione.py b/providers/asione.py index 0237ec7f..0d81dcc4 100644 --- a/providers/asione.py +++ b/providers/asione.py @@ -28,9 +28,6 @@ def loadOmegaPlugin(): class ASIOneProviderImpl(llm.AIProvider): """Lazy AI provider with on-demand initialization.""" - def __init__(self, name: str, var_name: str, model_name: str, base_url: str): - super().__init__(name, var_name, model_name, base_url) - def chat(self, content: str, max_tokens: int = 6000, reasoning: str = "medium", **kwargs) -> str: """Send chat request, initializing client if needed.""" self._ensure_client() diff --git a/providers/openrouter.py b/providers/openrouter.py index 40aca84b..b7e1f32a 100644 --- a/providers/openrouter.py +++ b/providers/openrouter.py @@ -31,21 +31,6 @@ def loadOmegaPlugin(): class OpenRouterProviderImpl(llm.AIProvider): """OpenRouter provider with reasoning mode enabled (reasoning tokens excluded from the response).""" - def _create_client(self) -> Optional[openai.OpenAI]: - """Create OpenRouter client from environment.""" - proxy_url = config_get_by_key("GATEWAY_URL") - if proxy_url: - base_url = f"{proxy_url.rstrip('/')}/openrouter/" - logger.info(f"[OpenRouterProviderImpl._create_client]: Connecting via proxy: {base_url}") - return openai.OpenAI( - api_key="proxy", - base_url=base_url, - ) - if self._var_name in os.environ: - return openai.OpenAI(api_key=os.environ.get(self._var_name), base_url=self._base_url) - - return None - def _openrouter_extra_body(self, content: str, max_tokens: int) -> Dict[str, Any]: sysmsg, _ = llm._split_system_user(content) body = { From ffffa155bb2f03cf80f7128e256824cdcd4fe93c Mon Sep 17 00:00:00 2001 From: Vitaly Bogdanov Date: Mon, 31 Aug 2026 19:16:30 +0300 Subject: [PATCH 3/9] Remove code duplication Move provider specific logic into prepare_args method mostly. The only exceptions are OpenAI because it uses completely different method to call the API and TestMock which doesn't require most of the things. --- providers/asione.py | 45 +++++++++------------- providers/lib_llm_ext.py | 23 ++++++++---- providers/openai.py | 81 ++++++++++++++++++++++------------------ providers/openrouter.py | 13 ++----- 4 files changed, 81 insertions(+), 81 deletions(-) diff --git a/providers/asione.py b/providers/asione.py index 0d81dcc4..edd9f8c6 100644 --- a/providers/asione.py +++ b/providers/asione.py @@ -2,6 +2,7 @@ import providers from src.logger import get_logger from config import config_get_by_key +from typing import Dict, Any logger = get_logger(__name__) @@ -28,31 +29,19 @@ def loadOmegaPlugin(): class ASIOneProviderImpl(llm.AIProvider): """Lazy AI provider with on-demand initialization.""" - def chat(self, content: str, max_tokens: int = 6000, reasoning: str = "medium", **kwargs) -> str: - """Send chat request, initializing client if needed.""" - self._ensure_client() - - if self._client is None: - raise RuntimeError(f"{self.name} not configured (set {self._var_name})") - - sysmsg, usermsg = content.split(":-:-:-:") - try: - response = self._client.chat.completions.create( - model=self._model_name, - messages=[{"role": "system", "content": sysmsg}, - {"role": "user", "content": usermsg}], - max_tokens=max_tokens, - extra_body={ - "enable_thinking": True, - "thinking_budget": 6000 - }, - **kwargs - ) - - raw = response.choices[0].message.content - llm._log_raw(self._name, self._model_name, raw) - resp = self._clean_text(raw) - return resp - except Exception as e: - logger.exception(f"[ASIOneProviderImpl.chat]: Exception while communicating with LLM: {e}") - return "" + def prepare_args(self, content: str, max_tokens: int = 6000, + reasoning: str = "medium", **kwargs) -> Dict[str, Any]: + sysmsg, usermsg = llm._split_system_user(content) + return { + "model": self._model_name, + "messages": [ + {"role": "system", "content": sysmsg}, + {"role": "user", "content": usermsg} + ], + "max_tokens": max_tokens, + "extra_body": { + "enable_thinking": True, + "thinking_budget": 6000 + }, + **kwargs + } diff --git a/providers/lib_llm_ext.py b/providers/lib_llm_ext.py index 31363f6b..1d4362e1 100644 --- a/providers/lib_llm_ext.py +++ b/providers/lib_llm_ext.py @@ -113,6 +113,18 @@ def _build_messages(self, content: str): return [{"role": "user", "content": usermsg}] + def prepare_args(self, content: str, max_tokens: int = 6000, + reasoning: str = "medium", **kwargs) -> Dict[str, Any]: + return { + "model": self._model_name, + "messages": self._build_messages(content), + "max_tokens": max_tokens, + **kwargs + } + + def extract_raw_response(self, response): + return response.choices[0].message.content or "" + def chat(self, content: str, max_tokens: int = 6000, reasoning: str = "medium", **kwargs) -> str: """Send chat request, initializing client if needed.""" self._ensure_client() @@ -121,14 +133,9 @@ def chat(self, content: str, max_tokens: int = 6000, reasoning: str = "medium", raise RuntimeError(f"{self.name} not configured (set {self._var_name})") try: - response = self._client.chat.completions.create( - model=self._model_name, - messages=self._build_messages(content), - max_tokens=max_tokens, - **kwargs - ) - - raw = response.choices[0].message.content or "" + kwargs = self.prepare_args(content, max_tokens, reasoning, **kwargs) + response = self._client.chat.completions.create(**kwargs) + raw = self.extract_raw_response(response) _log_raw(self._name, self._model_name, raw) resp = self._clean_text(raw) return resp diff --git a/providers/openai.py b/providers/openai.py index 5491ca9e..b729cb94 100644 --- a/providers/openai.py +++ b/providers/openai.py @@ -3,6 +3,7 @@ import providers from src.logger import get_logger from config import config_get_by_key +from typing import Dict, Any logger = get_logger(__name__) @@ -29,49 +30,57 @@ def loadOmegaPlugin(): class OpenAIProviderImpl(llm.AIProvider): """OpenAI provider using the Responses API (reasoning models).""" + def prepare_args(self, content: str, max_tokens: int = 6000, + reasoning: str = "medium", **kwargs) -> Dict[str, Any]: + sysmsg, usermsg = llm._split_system_user(content) + args = { + "instructions": sysmsg, + "model": self._model_name, + "input": usermsg, + "max_output_tokens": max_tokens, + "reasoning": {"effort": reasoning}, + "prompt_cache_key": config_get_by_key("OPENAI_PROMPT_CACHE_KEY", llm._stable_cache_key("openai", self._model_name, sysmsg)), + } + # GPT-5.5 supports only 24h; GPT-5.4 also supports extended retention. + if self._model_name.startswith(("gpt-5.5", "gpt-5.4")): + args["prompt_cache_retention"] = "24h" + + args.update(kwargs) + return args + + def extract_raw_response(self, response): + usage = getattr(response, "usage", None) + if usage: + input_tokens = getattr(usage, "input_tokens", None) + output_tokens = getattr(usage, "output_tokens", None) + total_tokens = getattr(usage, "total_tokens", None) + details = getattr(usage, "input_tokens_details", None) + cached_tokens = getattr(details, "cached_tokens", None) if details else None + + logger.info( + f"[LLM_USAGE] provider={self._name} model={self._model_name} " + f"input_tokens={input_tokens} output_tokens={output_tokens} " + f"total_tokens={total_tokens} cached_tokens={cached_tokens}" + ) + + return response.output_text or "" + def chat(self, content: str, max_tokens: int = 6000, reasoning: str = "medium", **kwargs) -> str: - """Send chat request via the Responses API, initializing client if needed.""" + """Send chat request, initializing client if needed.""" self._ensure_client() if self._client is None: raise RuntimeError(f"{self.name} not configured (set {self._var_name})") - sysmsg, usermsg = llm._split_system_user(content) - try: - create_kwargs = { - "instructions": sysmsg, - "model": self._model_name, - "input": usermsg, - "max_output_tokens": max_tokens, - "reasoning": {"effort": reasoning}, - "prompt_cache_key": config_get_by_key("OPENAI_PROMPT_CACHE_KEY", llm._stable_cache_key("openai", self._model_name, sysmsg)), - } - # GPT-5.5 supports only 24h; GPT-5.4 also supports extended retention. - if self._model_name.startswith(("gpt-5.5", "gpt-5.4")): - create_kwargs["prompt_cache_retention"] = "24h" - - create_kwargs.update(kwargs) - - response = self._client.responses.create(**create_kwargs) - - usage = getattr(response, "usage", None) - if usage: - input_tokens = getattr(usage, "input_tokens", None) - output_tokens = getattr(usage, "output_tokens", None) - total_tokens = getattr(usage, "total_tokens", None) - details = getattr(usage, "input_tokens_details", None) - cached_tokens = getattr(details, "cached_tokens", None) if details else None - - logger.info( - f"[LLM_USAGE] provider={self._name} model={self._model_name} " - f"input_tokens={input_tokens} output_tokens={output_tokens} " - f"total_tokens={total_tokens} cached_tokens={cached_tokens}" - ) - - raw = response.output_text or "" + kwargs = self.prepare_args(content, max_tokens, reasoning, **kwargs) + # TODO: This line is the only line which is different to + # super().chat() implementation + response = self._client.responses.create(**kwargs) + raw = self.extract_raw_response(response) llm._log_raw(self._name, self._model_name, raw) - return self._clean_text(raw) + resp = self._clean_text(raw) + return resp except Exception as e: - logger.exception(f"[OpenAIProviderImpl.chat]: Exception while communicating with LLM: {e}") + logger.exception(f"[AIProvider.chat]: Exception while communicating with LLM: {e}") return "" diff --git a/providers/openrouter.py b/providers/openrouter.py index b7e1f32a..a19cc32d 100644 --- a/providers/openrouter.py +++ b/providers/openrouter.py @@ -61,17 +61,12 @@ def _openrouter_extra_body(self, content: str, max_tokens: int) -> Dict[str, Any return body - - def chat(self, content: str, max_tokens: int = 6000, reasoning: str = "medium", **kwargs) -> str: + def prepare_args(self, content: str, max_tokens: int = 6000, + reasoning: str = "medium", **kwargs) -> Dict[str, Any]: extra_body = llm._merge_dicts( self._openrouter_extra_body(content, max_tokens), kwargs.pop("extra_body", None), ) - return super().chat( - content=content, - max_tokens=max_tokens, - reasoning=reasoning, - extra_body=extra_body, - **kwargs, - ) + return super().prepare_args(content, max_tokens, reasoning, + extra_body=extra_body, **kwargs) From dde186060b8bc11597dbe25280f7d5b3d6091f77 Mon Sep 17 00:00:00 2001 From: Vitaly Bogdanov Date: Mon, 21 Sep 2026 17:50:52 +0300 Subject: [PATCH 4/9] Use API fields to pass list of tools to the LLM Change the LLM call API to pass the list of tools to call and receive the list of tool calls. Adapt loop.metta to this change. Fix unit tests. --- .github/workflows/autotests.yml | 2 +- Autotests/mock/README.md | 8 +- Autotests/mock/llm.py | 91 +++--- .../mock/test_complex_weather_flow_mock.py | 14 +- Autotests/mock/test_convert_format_mock.py | 2 +- Autotests/mock/test_create_empty_file_mock.py | 7 +- Autotests/mock/test_create_file_mock.py | 4 +- Autotests/mock/test_create_script_mock.py | 9 +- .../mock/test_credentials_scrubbed_mock.py | 2 +- .../mock/test_edit_add_timestamp_mock.py | 2 +- Autotests/mock/test_edit_append_line_mock.py | 2 +- Autotests/mock/test_edit_delete_line_mock.py | 2 +- Autotests/mock/test_git_local_commit_mock.py | 11 +- Autotests/mock/test_git_pull_public_mock.py | 2 +- .../mock/test_git_push_to_remote_mock.py | 2 +- Autotests/mock/test_io_policy_skill_mock.py | 16 +- ...st_skill_results_visible_next_turn_mock.py | 17 +- Autotests/mock/test_llm.py | 35 ++- Autotests/mock/test_memory_chromadb_mock.py | 2 +- Autotests/mock/test_memory_episode_mock.py | 5 +- ...ory_history_byte_window_truncation_mock.py | 4 +- Autotests/mock/test_memory_history_mock.py | 2 +- .../test_memory_missing_history_file_mock.py | 4 +- .../test_memory_pin_window_visibility_mock.py | 5 +- Autotests/mock/test_openclaw_delegate_mock.py | 90 +++--- ...est_pin_invisible_within_iteration_mock.py | 20 +- .../mock/test_prompt_missing_files_mock.py | 6 +- Autotests/mock/test_run_create_dirs_mock.py | 6 +- Autotests/mock/test_run_error_script_mock.py | 2 +- Autotests/mock/test_run_repeated_mock.py | 2 +- Autotests/mock/test_search_basic_mock.py | 2 +- Autotests/mock/test_search_invalid_mock.py | 4 +- Autotests/mock/test_search_weather_mock.py | 2 +- Autotests/mock/test_skill_episodes_mock.py | 5 +- Autotests/mock/test_skill_metta_mock.py | 3 +- Autotests/mock/test_skill_pin_mock.py | 4 +- Autotests/mock/test_skill_query_mock.py | 10 +- ...transition_episodes_after_eviction_mock.py | 7 +- .../test_transition_metta_to_remember_mock.py | 14 +- .../test_transition_pin_to_remember_mock.py | 8 +- Autotests/mock/test_workflow_plugin_mock.py | 20 +- Autotests/mock_memory/README.md | 6 +- Autotests/mock_slack/README.md | 4 +- .../test_git_pull_public_slack_mock.py | 2 +- .../test_git_push_to_remote_slack_mock.py | 2 +- .../test_memory_history_slack_mock.py | 2 +- .../test_run_repeated_slack_mock.py | 2 +- .../test_search_basic_slack_mock.py | 2 +- .../test_search_invalid_slack_mock.py | 6 +- .../test_search_weather_slack_mock.py | 2 +- .../mock_slack/test_skill_pin_slack_mock.py | 9 +- .../mock_slack/test_skill_query_slack_mock.py | 9 +- Autotests/mock_websocket/README.md | 2 +- lib_omega.metta | 3 +- plugins/openclaw/openclaw.metta | 2 +- .../research-workflow/skill.metta | 20 +- .../instructions/test-workflow/skill.metta | 2 +- plugins/workflow/workflow.metta | 22 +- providers/asione.py | 27 +- providers/lib_llm_ext.py | 100 +++++-- providers/mockprovider.py | 14 +- providers/openai.py | 128 ++++++-- providers/openaiapi.py | 4 +- providers/openrouter.py | 27 +- src/loop.metta | 42 ++- src/plugin.metta | 3 +- src/providers.metta | 39 ++- src/providers.py | 281 +++++++++++++++++- src/skills.metta | 13 +- src/utils.metta | 5 +- src/utils.py | 9 + tests/src_skills.metta | 4 +- tests/src_utils.metta | 4 + 73 files changed, 863 insertions(+), 389 deletions(-) create mode 100644 src/utils.py diff --git a/.github/workflows/autotests.yml b/.github/workflows/autotests.yml index ca7b650d..3dbb0115 100644 --- a/.github/workflows/autotests.yml +++ b/.github/workflows/autotests.yml @@ -108,7 +108,7 @@ jobs: echo "Waiting for agent to become ready..." ready= for i in $(seq 1 90); do - if docker logs omega 2>&1 | grep -qE "CHARS_SENT: [0-9]+"; then + if docker logs omega 2>&1 | grep -qE "iteration 1"; then echo "Agent ready after ${i}s" ready=1 break diff --git a/Autotests/mock/README.md b/Autotests/mock/README.md index c33ebbb0..232cca51 100644 --- a/Autotests/mock/README.md +++ b/Autotests/mock/README.md @@ -37,10 +37,10 @@ Notes: - `TEST_SERVER_IP=172.17.0.1` is the host's docker-bridge address used by both the mock LLM provider and the test channel client. - The container is created with the name `omega` (the script default). -Wait until the agent loop is up. The first runtime `CHARS_SENT:` line (with a byte count after the colon) in the container log marks the end of `initChannels` / `initMemory` and the start of real iterations; the bare `CHARS_SENT:` string also appears earlier as part of the MeTTa source dump, so match on the numeric form to avoid a premature exit: +Wait until the agent loop is up. The first runtime `iteration 1` line (with a byte count after the colon) in the container log marks the end of `initChannels` / `initMemory` and the start of real iterations; the bare `iteration` string also appears earlier as part of the MeTTa source dump, so match on the numeric form to avoid a premature exit: ``` -until docker logs omega 2>&1 | grep -qE "CHARS_SENT: [0-9]+"; do sleep 2; done +until docker logs omega 2>&1 | grep -qE "iteration 1"; do sleep 2; done ``` ## 4. Configure the test environment @@ -305,7 +305,7 @@ Four-step pipeline: search NY weather → write `w.txt` with the forecast → wr Verifies the one-iteration carry of `LAST_SKILL_USE_RESULTS`. Output of a skill call in turn N is exposed to the LLM at turn N+1 via this prompt section. The test does not require the agent to "behave intelligently"; it confirms the carry exists. - Mock answer (turn 1): `(metta "(+ 1 1)")`. -- Checks: the docker log line `CHARS_SENT:` for the next iteration contains a `LAST_SKILL_USE_RESULTS` section that reflects the metta output. +- Checks: the docker log line `REQUEST:` for the next iteration contains a last tool call section that reflects the metta output. ### 26. test_memory_history_byte_window_truncation_mock.py @@ -327,7 +327,7 @@ A `(pin ...)` emitted in turn 1 must land in `history.metta` and remain inside t Negative test: a `(pin ...)` emitted within an iteration is NOT visible inside that same iteration's HISTORY context. The prompt is assembled before skill evaluation, so the pin block, written by `addToHistory` at the end of the iteration, only enters HISTORY at the next prompt-build. - Mock answer: `(pin "")` followed by a `(send ...)`. -- Checks: the `CHARS_SENT` line carrying the PROMPT for the iteration that contained the pin does NOT contain the pin's unique marker; the next iteration's `CHARS_SENT` line does. +- Checks: the `REQUEST` line carrying the PROMPT for the iteration that contained the pin does NOT contain the pin's unique marker; the next iteration's `REQUEST` line does. ### 29. test_transition_episodes_after_eviction_mock.py diff --git a/Autotests/mock/llm.py b/Autotests/mock/llm.py index cc3ec749..5a0cf547 100644 --- a/Autotests/mock/llm.py +++ b/Autotests/mock/llm.py @@ -7,6 +7,7 @@ from rpc import Rpc, IPCClient, IPCServer from contextlib import contextmanager import threading +from providers import * LLM_MOCK_PORT = 9765 @@ -23,53 +24,47 @@ def __init__(self, address): def stop(self, timeout=None): self._rpc.stop(timeout) - def chat(self, content): - user = content.rsplit(":-:-:-:", 1) - if len(user) < 2: - return "" - - try: - body = eval(user[1])[1] - except SyntaxError: - return "" - - # The agent escapes punctuation that would confuse its s-exp - # parser ('->_apostrophe_, "->_quote_, \n->_newline_) before - # the text reaches chat(). set_answer stores the literal - # prompt key, so try the raw body first, then the normalized - # form so prompts with quotes/apostrophes/newlines still match. - def normalize(text): - return (text - .replace("_apostrophe_", "'") - .replace("_quote_", '"') - .replace("_newline_", "\n")) + def chat(self, request: LLMRequest) -> LLMResponse: + user = [m for m in request.messages if m.role == "user"] + answer = None + if len(user) > 0: + body = user[-1].content + + # The agent escapes punctuation that would confuse its s-exp + # parser ('->_apostrophe_, "->_quote_, \n->_newline_) before + # the text reaches chat(). set_answer stores the literal + # prompt key, so try the raw body first, then the normalized + # form so prompts with quotes/apostrophes/newlines still match. + def normalize(text): + return (text + .replace("_apostrophe_", "'") + .replace("_quote_", '"') + .replace("_newline_", "\n")) - with self._lock: - answer = self._answers.get(body) or self._answers.get(normalize(body)) - if answer: - print(f"[LlmMockAgent] Mock answers: {answer}") - return answer - - # IRC may deliver multiple PRIVMSGs in one agent iteration; the - # agent concatenates them with " | " between speakers. Split - # and look up each fragment individually so a registered answer - # is not missed when several messages arrive together. - fragments = body.split(" | ") - for fragment in fragments: - if ": " not in fragment: - continue - prompt = fragment.split(": ", 1)[1] with self._lock: - a = self._answers.get(normalize(prompt)) or self._answers.get(prompt) - if a: - answer = a + answer = self._answers.get(body) or self._answers.get(normalize(body)) + + if not answer: + # IRC may deliver multiple PRIVMSGs in one agent iteration; the + # agent concatenates them with " | " between speakers. Split + # and look up each fragment individually so a registered answer + # is not missed when several messages arrive together. + fragments = body.split(" | ") + for fragment in fragments: + if ": " not in fragment: + continue + prompt = fragment.split(": ", 1)[1] + with self._lock: + a = self._answers.get(normalize(prompt)) or self._answers.get(prompt) + if a: + answer = a if answer: print(f"[LlmMockAgent] Mock answers: {answer}") - return answer + return self._make_llm_response(answer) else: print(f"[LlmMockAgent] Mock doesn't have answer for: {body}") - return "" + return LLMResponse() def on_set_answer(self, args): with self._lock: @@ -80,9 +75,21 @@ def on_set_answer(self, args): return True def on_ping(self, args): - print(f'[LlmMockAgent] Mock ping request processed') + print('[LlmMockAgent] Mock ping request processed') return True + def _make_llm_response(self, calls: [(str, dict[str, str])]) -> LLMResponse: + response = LLMResponse() + id = 0 + for (func, args) in calls: + response.add_tool_call(LLMToolCall() + .with_name(func) + .with_id(f"mockid#{id}") + .with_arguments(args)) + id = id + 1 + return response + + class LlmMockController: def __init__(self, address): @@ -100,7 +107,7 @@ def set_answer(self, request, response, timeout=10): return True def ping(self, timeout=None): - print(f'[LlmMockController] Ping agent') + print('[LlmMockController] Ping agent') result = self._rpc.request('ping', {}) if result.get(timeout) != True: print(f'[LlmMockController] Did not get answer on ping in {timeout} seconds') diff --git a/Autotests/mock/test_complex_weather_flow_mock.py b/Autotests/mock/test_complex_weather_flow_mock.py index 1cd9ff9e..1b1ec960 100644 --- a/Autotests/mock/test_complex_weather_flow_mock.py +++ b/Autotests/mock/test_complex_weather_flow_mock.py @@ -56,13 +56,13 @@ def test_complex_weather_flow_mock(llm, comm): ) # Single LLM response containing the full pipeline. llm.set_answer( - prompt, - f'(write-file "{WEATHER_TXT}" "{FORECAST_TEXT}") ' - f'(write-file "{SCRIPT_SH}" ' - f'"#!/bin/bash\\ngrep -oE \'[0-9]+\' {WEATHER_TXT} | head -1 > {TEMP_ONLY}\\n") ' - f'(shell "chmod +x {SCRIPT_SH}") ' - f'(shell "sh {SCRIPT_SH}")', - ) + prompt, [ + ("write-file", { "filename": f"{WEATHER_TXT}", "content": f"{FORECAST_TEXT}" }), + ("write-file", { "filename": f"{SCRIPT_SH}", "content": + f"#!/bin/bash\\ngrep -oE '[0-9]+' {WEATHER_TXT} | head -1 > {TEMP_ONLY}\\n" }), + ("shell", { "cmd": f"chmod +x {SCRIPT_SH}" }), + ("shell", { "cmd": f"sh {SCRIPT_SH}" }) + ]) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within 60s") c.ok("comm", f"run-id={c.run_id}") diff --git a/Autotests/mock/test_convert_format_mock.py b/Autotests/mock/test_convert_format_mock.py index 19066c6a..24b099b0 100644 --- a/Autotests/mock/test_convert_format_mock.py +++ b/Autotests/mock/test_convert_format_mock.py @@ -50,7 +50,7 @@ def test_convert_md_to_txt_mock(llm, comm): ) llm.set_answer( prompt, - f'(shell "cp {SOURCE_FILE} {DEST_FILE}")', + [("shell", {"cmd": f"cp {SOURCE_FILE} {DEST_FILE}"})] ) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within 60s") diff --git a/Autotests/mock/test_create_empty_file_mock.py b/Autotests/mock/test_create_empty_file_mock.py index 101c5b5c..3a09920a 100644 --- a/Autotests/mock/test_create_empty_file_mock.py +++ b/Autotests/mock/test_create_empty_file_mock.py @@ -36,9 +36,10 @@ def test_create_empty_file_mock(llm, comm): "(create the directory if needed). The file can be empty.", ) llm.set_answer( - prompt, - f'(shell "mkdir -p {TARGET_DIR}") ' - f'(write-file "{TARGET_FILE}" "")', + prompt, [ + ("shell", { "cmd": f"mkdir -p {TARGET_DIR}" }), + ("write-file", { "filename": f"{TARGET_FILE}", "content": "" }) + ] ) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within 60s") diff --git a/Autotests/mock/test_create_file_mock.py b/Autotests/mock/test_create_file_mock.py index bef769eb..d8334972 100644 --- a/Autotests/mock/test_create_file_mock.py +++ b/Autotests/mock/test_create_file_mock.py @@ -30,7 +30,9 @@ def test_hello_file(llm, comm): f"Please overwrite {TARGET_FILE} so it contains exactly the single " "word Hello (no quotes, no extra newlines, create the directory if needed).", ) - llm.set_answer(prompt, f'(shell "mkdir -p /tmp/testcat") (write-file "/tmp/testcat/hello.txt" "Hello")') + llm.set_answer(prompt, [("shell", { "cmd": "mkdir -p /tmp/testcat" }), + ("write-file", { "filename": "/tmp/testcat/hello.txt", + "content": "Hello" })]) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within 60s") diff --git a/Autotests/mock/test_create_script_mock.py b/Autotests/mock/test_create_script_mock.py index dc841fc4..ce318923 100644 --- a/Autotests/mock/test_create_script_mock.py +++ b/Autotests/mock/test_create_script_mock.py @@ -47,10 +47,11 @@ def test_create_date_script_mock(llm, comm): "Create the directory if needed.", ) llm.set_answer( - prompt, - f'(shell "mkdir -p {TARGET_DIR}") ' - f'(write-file "{TARGET_FILE}" "#!/bin/bash\\ndate\\n") ' - f'(shell "chmod +x {TARGET_FILE}")', + prompt, [ + ("shell", { "cmd": f"mkdir -p {TARGET_DIR}" }), + ("write-file", { "filename": f"{TARGET_FILE}", "content": "#!/bin/bash\\ndate\\n" }), + ("shell", { "cmd": f"chmod +x {TARGET_FILE}" }), + ] ) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within 60s") diff --git a/Autotests/mock/test_credentials_scrubbed_mock.py b/Autotests/mock/test_credentials_scrubbed_mock.py index 38a75dbb..3b0e1617 100644 --- a/Autotests/mock/test_credentials_scrubbed_mock.py +++ b/Autotests/mock/test_credentials_scrubbed_mock.py @@ -42,7 +42,7 @@ def test_agent_env_has_no_secrets(llm, comm): c.run_id, f"Use the shell skill to write your environment to a file: shell env > {DUMP}", ) - llm.set_answer(prompt, f'(shell "env > {DUMP}")') + llm.set_answer(prompt, [("shell", { "cmd": f"env > {DUMP}" })]) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within 60s") diff --git a/Autotests/mock/test_edit_add_timestamp_mock.py b/Autotests/mock/test_edit_add_timestamp_mock.py index 51e03c23..5dfe4067 100644 --- a/Autotests/mock/test_edit_add_timestamp_mock.py +++ b/Autotests/mock/test_edit_add_timestamp_mock.py @@ -52,7 +52,7 @@ def test_edit_add_timestamp_mock(llm, comm): # real test. llm.set_answer( prompt, - f'(shell "date -Iseconds >> {TARGET_FILE}")', + [("shell", { "cmd": f"date -Iseconds >> {TARGET_FILE}" })] ) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within 60s") diff --git a/Autotests/mock/test_edit_append_line_mock.py b/Autotests/mock/test_edit_append_line_mock.py index a0f667cc..1f5c58ad 100644 --- a/Autotests/mock/test_edit_append_line_mock.py +++ b/Autotests/mock/test_edit_append_line_mock.py @@ -51,7 +51,7 @@ def test_edit_append_line_mock(llm, comm): ) llm.set_answer( prompt, - f'(shell "printf \'%s\\\\n\' {LINE4_EXPECTED} >> {TARGET_FILE}")', + [("shell", { "cmd": f"printf \'%s\\\\n\' {LINE4_EXPECTED} >> {TARGET_FILE}" })] ) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within 60s") diff --git a/Autotests/mock/test_edit_delete_line_mock.py b/Autotests/mock/test_edit_delete_line_mock.py index 5fff7c51..2d00b3ac 100644 --- a/Autotests/mock/test_edit_delete_line_mock.py +++ b/Autotests/mock/test_edit_delete_line_mock.py @@ -51,7 +51,7 @@ def test_edit_delete_line_mock(llm, comm): ) llm.set_answer( prompt, - f'(shell "sed -i 2d {TARGET_FILE}")', + [("shell", { "cmd": f"sed -i 2d {TARGET_FILE}" })] ) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within 60s") diff --git a/Autotests/mock/test_git_local_commit_mock.py b/Autotests/mock/test_git_local_commit_mock.py index f33a81e9..aff0dde8 100644 --- a/Autotests/mock/test_git_local_commit_mock.py +++ b/Autotests/mock/test_git_local_commit_mock.py @@ -53,11 +53,12 @@ def test_git_local_commit_mock(llm, comm): # needs the message in single-quoted shell string while the s-exp # arg itself is double-quoted, so we escape inner quotes. llm.set_answer( - prompt, - f'(shell "git -C {TARGET_DIR} init") ' - f'(write-file "{commit_path}" "{marker}") ' - f'(shell "git -C {TARGET_DIR} add -A") ' - f'(shell "git -C {TARGET_DIR} commit -m \\"add hello {c.run_id}\\"")', + prompt, [ + ("shell", { "cmd": f"git -C {TARGET_DIR} init" }), + ("write-file", { "filename": f"{commit_path}", "content": f"{marker}" }), + ("shell", { "cmd": f"git -C {TARGET_DIR} add -A" }), + ("shell", { "cmd": f'git -C {TARGET_DIR} commit -m "add hello {c.run_id}"' }) + ] ) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within 60s") diff --git a/Autotests/mock/test_git_pull_public_mock.py b/Autotests/mock/test_git_pull_public_mock.py index 8a5a6bff..69981459 100644 --- a/Autotests/mock/test_git_pull_public_mock.py +++ b/Autotests/mock/test_git_pull_public_mock.py @@ -69,7 +69,7 @@ def test_git_pull_public_mock(llm, comm): ) llm.set_answer( prompt, - f'(shell "{clone_command}")', + [("shell", { "cmd": f"{clone_command}" })] ) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within 60s") diff --git a/Autotests/mock/test_git_push_to_remote_mock.py b/Autotests/mock/test_git_push_to_remote_mock.py index 00a01b33..4726a2be 100644 --- a/Autotests/mock/test_git_push_to_remote_mock.py +++ b/Autotests/mock/test_git_push_to_remote_mock.py @@ -107,7 +107,7 @@ def test_git_push_to_remote_mock(llm, comm): f"git commit -m 'qa run {c.run_id}' && " f"git push -u origin {branch}" ) - llm.set_answer(prompt, f'(shell "{chain}")') + llm.set_answer(prompt, [("shell", { "cmd": f"{chain}" })]) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within 60s") c.ok("comm", f"run-id={c.run_id}") diff --git a/Autotests/mock/test_io_policy_skill_mock.py b/Autotests/mock/test_io_policy_skill_mock.py index b4d3e864..1d80be56 100644 --- a/Autotests/mock/test_io_policy_skill_mock.py +++ b/Autotests/mock/test_io_policy_skill_mock.py @@ -27,10 +27,10 @@ def test_get_io_policy_mock(llm, comm): prompt = make_prompt(c.run_id, "Check your IO policy.") llm.set_answer( request=prompt, - response=( - f'(send "Checking my io policy {c.run_id}")\n' - '(get-io-policy)' - ) + response=([ + ("send", { "content": f"Checking my io policy {c.run_id}" }), + ("get-io-policy", {}) + ]) ) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within timeout") @@ -51,10 +51,10 @@ def test_get_io_policy_mock(llm, comm): prompt = make_prompt(c.run_id, "Retrieve the current filesystem access policy.") llm.set_answer( request=prompt, - response=( - f'(metta (write-file "{JSON_POLICY_OUTPUT_PATH}" (get-io-policy)))\n' - f'(send "Policy checked for {c.run_id}")' - ) + response=([ + ("metta", { "sexpression": f'(write-file "{JSON_POLICY_OUTPUT_PATH}" (get-io-policy))' }), + ("send", { "content": f"Policy checked for {c.run_id}" }) + ]) ) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within timeout") diff --git a/Autotests/mock/test_last_skill_results_visible_next_turn_mock.py b/Autotests/mock/test_last_skill_results_visible_next_turn_mock.py index c31e6020..63c6cb6a 100644 --- a/Autotests/mock/test_last_skill_results_visible_next_turn_mock.py +++ b/Autotests/mock/test_last_skill_results_visible_next_turn_mock.py @@ -5,7 +5,7 @@ of (metta ...), (query ...), (shell ...) etc. without persisting it. Turn 1 mock answer dictates a metta computation. We then read the -docker log to find the CHARS_SENT line for the NEXT iteration and +docker log to find the REQUEST line for the NEXT iteration and confirm it contains the LAST_SKILL_USE_RESULTS marker. Run: @@ -46,7 +46,10 @@ def test_last_skill_results_visible_next_turn_mock(llm, comm): # the next iteration's LAST_SKILL_USE_RESULTS. llm.set_answer( prompt1, - f'(metta "(quote {sentinel})") (send "computed")', + [ + ("metta", { "sexpression": f"(quote {sentinel})" }), + ("send", { "content": "computed" }) + ] ) if not comm.send_message(prompt1): c.fail("comm-1", "could not deliver turn 1 prompt within 60s") @@ -69,13 +72,13 @@ def test_last_skill_results_visible_next_turn_mock(llm, comm): c.step("wait for the agent to start a fresh iteration") time.sleep(20) - c.step("verify next iteration's CHARS_SENT contains LAST_SKILL_USE_RESULTS with sentinel") + c.step("verify next iteration's REQUEST contains LAST_SKILL_USE_RESULTS with sentinel") logs = docker_logs() - # We look for any CHARS_SENT line after our metta call that carries + # We look for any REQUEST line after our metta call that carries # the sentinel inside the LAST_SKILL_USE_RESULTS section. chars_sent_lines = [ ln for ln in logs.split("\n") - if "CHARS_SENT:" in ln and "LAST_SKILL_USE_RESULTS:" in ln + if "REQUEST:" in ln and "LAST_SKILL_USE_RESULTS" in ln ] relevant = [ ln for ln in chars_sent_lines @@ -83,8 +86,8 @@ def test_last_skill_results_visible_next_turn_mock(llm, comm): ] if not relevant: c.fail("sentinel in lastresults", - f"no CHARS_SENT line carries {sentinel!r} in " - f"LAST_SKILL_USE_RESULTS. Total CHARS_SENT lines " + f"no REQUEST line carries {sentinel!r} in " + f"LAST_SKILL_USE_RESULTS. Total REQUEST lines " f"checked: {len(chars_sent_lines)}") c.ok("sentinel in lastresults", f"found in {len(relevant)} subsequent iteration prompt(s)") diff --git a/Autotests/mock/test_llm.py b/Autotests/mock/test_llm.py index e9300455..71d2f19d 100644 --- a/Autotests/mock/test_llm.py +++ b/Autotests/mock/test_llm.py @@ -2,6 +2,7 @@ from llm import * from rpc import LOCALHOST +from providers import * TEST_ADDRESS = (LOCALHOST, 9767) @@ -35,27 +36,27 @@ def controller(self): controller.stop(5) def test_response(self, agent, controller): - assert controller.set_answer("hello", "world") - assert agent.chat(":-:-:-:['HUMAN-MSG', 'test: hello']") == "world" + assert controller.set_answer("hello", _llm_response_json("world")) + assert agent.chat(_llm_request('test: hello')) == _llm_response("world") def test_test_restart(self, agent): controller = LlmMockController(TEST_ADDRESS) - assert controller.set_answer("hello", "world") - assert agent.chat(":-:-:-:['HUMAN-MSG', 'test: hello']") == "world" + assert controller.set_answer("hello", _llm_response_json("world")) + assert agent.chat(_llm_request('test: hello')) == _llm_response("world") controller.stop(5) controller = LlmMockController(TEST_ADDRESS) - assert controller.set_answer("hello", "earth") - assert agent.chat(":-:-:-:['HUMAN-MSG', 'test: hello']") == "earth" + assert controller.set_answer("hello", _llm_response_json("earth")) + assert agent.chat(_llm_request('test: hello')) == _llm_response("earth") controller.stop(5) def test_no_message(self, agent, controller): - assert controller.set_answer("hello", "world") - assert agent.chat(":-:-:-:DO NOT RE-SEND OR SPAM!") == "" + assert controller.set_answer("hello", _llm_response_json("world")) + assert agent.chat(_llm_request("DO NOT RE-SEND OR SPAM!")) == LLMResponse() def test_context_manager(self, agent): with llm_mock_controller(address=TEST_ADDRESS) as controller: - assert controller.set_answer("hello", "world") - assert agent.chat(":-:-:-:['HUMAN-MSG', 'test: hello']") == "world" + assert controller.set_answer("hello", _llm_response_json("world")) + assert agent.chat(_llm_request('test: hello')) == _llm_response("world") def test_context_manager_timeout(self, agent): address = (TEST_ADDRESS[0], TEST_ADDRESS[1] + 1) @@ -64,3 +65,17 @@ def test_context_manager_timeout(self, agent): assert False except RuntimeError as e: assert e.args == ("Agent didn't answered in 2 seconds",) + +def _llm_request(msg): + return (LLMRequest().add_message( + LLMMessage().with_role("user").with_content(msg))) + +def _llm_response_json(msg): + return [("send", { "content": msg })] + +def _llm_response(msg): + return (LLMResponse().add_tool_call( + LLMToolCall().with_name("send") + .with_id("mockid#0") + .add_argument("content", msg))) + diff --git a/Autotests/mock/test_memory_chromadb_mock.py b/Autotests/mock/test_memory_chromadb_mock.py index dc91a178..40e28e61 100644 --- a/Autotests/mock/test_memory_chromadb_mock.py +++ b/Autotests/mock/test_memory_chromadb_mock.py @@ -59,7 +59,7 @@ def test_memory_chromadb_mock(llm, comm): ) llm.set_answer( prompt, - f'(remember "Unique smoke marker {marker} was emitted by CI.")', + [("remember", { "content": f"Unique smoke marker {marker} was emitted by CI." })] ) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within 60s") diff --git a/Autotests/mock/test_memory_episode_mock.py b/Autotests/mock/test_memory_episode_mock.py index e219fee6..37578807 100644 --- a/Autotests/mock/test_memory_episode_mock.py +++ b/Autotests/mock/test_memory_episode_mock.py @@ -46,7 +46,7 @@ def test_memory_episode_mock(llm, comm): ) llm.set_answer( prompt1, - '(remember "Barney the dog lost his first baby tooth at the vet today.")', + [("remember", { "content": f"Barney the dog lost his first baby tooth at the vet today." })] ) if not comm.send_message(prompt1): c.fail("comm-1", "could not deliver first prompt within 60s") @@ -89,7 +89,8 @@ def is_barney_memory(s): ) llm.set_answer( prompt2, - f'(query "Barney tooth") (send "{recall_reply}")', + [("query", { "content": "Barney tooth" }), + ("send", { "content": f"{recall_reply}" })] ) if not comm.send_message(prompt2): c.fail("comm-2", "could not deliver recall prompt within 60s") diff --git a/Autotests/mock/test_memory_history_byte_window_truncation_mock.py b/Autotests/mock/test_memory_history_byte_window_truncation_mock.py index b69471c1..cf1b3c32 100644 --- a/Autotests/mock/test_memory_history_byte_window_truncation_mock.py +++ b/Autotests/mock/test_memory_history_byte_window_truncation_mock.py @@ -51,7 +51,7 @@ def test_memory_history_byte_window_truncation_mock(llm, comm): c.run_id, f"Send back exactly this token in a single send: {early_marker}", ) - llm.set_answer(prompt1, f'(send "{early_marker}")') + llm.set_answer(prompt1, [("send", { "content": f"{early_marker}" })]) if not comm.send_message(prompt1): c.fail("comm-1", "could not deliver turn 1 prompt within 60s") c.ok("comm-1", f"run-id={c.run_id}") @@ -84,7 +84,7 @@ def test_memory_history_byte_window_truncation_mock(llm, comm): c.add_cleanup_marker(str(c.run_id + 1)) llm.set_answer( prompt2, - f'(remember "{padding_body}") (send "padded")', + [("remember", { "content": f"{padding_body}" })] ) if not comm.send_message(prompt2): c.fail("comm-2", "could not deliver padding prompt within 60s") diff --git a/Autotests/mock/test_memory_history_mock.py b/Autotests/mock/test_memory_history_mock.py index fd262564..8968f423 100644 --- a/Autotests/mock/test_memory_history_mock.py +++ b/Autotests/mock/test_memory_history_mock.py @@ -39,7 +39,7 @@ def test_memory_history_mock(llm, comm): f"Acknowledge with one short line that you received marker {c.run_id}.", ) ack = f"Marker {c.run_id} received. REQ-{c.run_id} acknowledged." - llm.set_answer(prompt, f'(send "{ack}")') + llm.set_answer(prompt, [("send", { "content": f"{ack}" })]) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within 60s") c.ok("comm", f"run-id={c.run_id}") diff --git a/Autotests/mock/test_memory_missing_history_file_mock.py b/Autotests/mock/test_memory_missing_history_file_mock.py index fd8ab481..cc1e6daa 100644 --- a/Autotests/mock/test_memory_missing_history_file_mock.py +++ b/Autotests/mock/test_memory_missing_history_file_mock.py @@ -30,7 +30,7 @@ def test_history_recreation_mock(llm, comm): c.step("Send message to trigger read and write") prompt = make_prompt(c.run_id, "Testing history recreation.") - llm.set_answer(prompt, f'(send "History tested {c.run_id}")') + llm.set_answer(prompt, [("send", { "content": f"History tested {c.run_id}" })]) if not comm.send_message(prompt): c.fail("comm", "Failed to deliver prompt") @@ -51,4 +51,4 @@ def test_history_recreation_mock(llm, comm): c.step("Teardown: restore history.metta from backup") dexec_root("sh", "-c", f"mv -f {HISTORY_BAK} {HISTORY_FILE} 2>/dev/null || true") - c.done() \ No newline at end of file + c.done() diff --git a/Autotests/mock/test_memory_pin_window_visibility_mock.py b/Autotests/mock/test_memory_pin_window_visibility_mock.py index 2fc72444..2860199f 100644 --- a/Autotests/mock/test_memory_pin_window_visibility_mock.py +++ b/Autotests/mock/test_memory_pin_window_visibility_mock.py @@ -43,7 +43,8 @@ def test_memory_pin_window_visibility_mock(llm, comm): ) llm.set_answer( prompt1, - f'(pin "{pin_marker}") (send "Pinned {pin_marker}.")', + [("pin", { "message": f"{pin_marker}" }), + ("send", { "content": f"Pinned {pin_marker}." })] ) if not comm.send_message(prompt1): c.fail("comm-1", "could not deliver turn 1 prompt within 60s") @@ -94,7 +95,7 @@ def test_memory_pin_window_visibility_mock(llm, comm): ) llm.set_answer( prompt2, - f'(send "I pinned {pin_marker} previously.")', + [("send", { "content": f"I pinned {pin_marker} previously." })] ) if not comm.send_message(prompt2): c.fail("comm-2", "could not deliver turn 2 prompt within 60s") diff --git a/Autotests/mock/test_openclaw_delegate_mock.py b/Autotests/mock/test_openclaw_delegate_mock.py index 97349993..2b98f933 100644 --- a/Autotests/mock/test_openclaw_delegate_mock.py +++ b/Autotests/mock/test_openclaw_delegate_mock.py @@ -53,10 +53,10 @@ def test_delegate_isolated_and_success_mock(llm, comm, gateway): prompt = make_prompt(c.run_id, "Delegate a task to OpenClaw.") llm.set_answer( request=prompt, - response=( - f'(send "Delegating task {c.run_id}")\n' - '(delegate-task-to-openclaw-agent "Reply with exactly: unused")' - ), + response=([ + ("send", { "content": f"Delegating task {c.run_id}" }), + ("delegate-task-to-openclaw-agent", { "task": "Reply with exactly: unused" }) + ]), ) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within timeout") @@ -80,11 +80,11 @@ def test_delegate_isolated_and_success_mock(llm, comm, gateway): prompt = make_prompt(c.run_id + 1, "Delegate a task and save the raw result.") llm.set_answer( request=prompt, - response=( - f'(metta (write-file "{out_path}" ' - f'(delegate-task-to-openclaw-agent "Reply with exactly: {echo_marker}")))\n' - f'(send "Delegation saved {c.run_id}")' - ), + response=([ + ("metta", { "sexpression": f'(write-file "{out_path}" ' + f'(delegate-task-to-openclaw-agent "Reply with exactly: {echo_marker}"))' }), + ("send", { "content": f"Delegation saved {c.run_id}" }) + ]), ) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within timeout") @@ -149,10 +149,10 @@ def test_delegate_empty_message_mock(llm, comm, gateway): prompt = make_prompt(c.run_id, "Delegate an empty task and save the raw result.") llm.set_answer( request=prompt, - response=( - f'(metta (write-file "{out_path}" (delegate-task-to-openclaw-agent "")))\n' - f'(send "Empty delegation checked {c.run_id}")' - ), + response=([ + ("metta", { "sexpression": f'(write-file "{out_path}" (delegate-task-to-openclaw-agent ""))' }), + ("send", { "content": f"Empty delegation checked {c.run_id}" }) + ]), ) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within timeout") @@ -188,13 +188,13 @@ def test_delegate_new_session_per_call_mock(llm, comm, gateway): prompt = make_prompt(c.run_id, "Delegate two independent tasks and save both raw results.") llm.set_answer( request=prompt, - response=( - f'(metta (write-file "{first_path}" ' - f'(delegate-task-to-openclaw-agent "Reply with exactly: {first_marker}")))\n' - f'(metta (write-file "{second_path}" ' - f'(delegate-task-to-openclaw-agent "Reply with exactly: {second_marker}")))\n' - f'(send "Both delegations saved {c.run_id}")' - ), + response=([ + ("metta", { "sexpression": f'(write-file "{first_path}" ' + f'(delegate-task-to-openclaw-agent "Reply with exactly: {first_marker}"))' }), + ("metta", { "sexpression": f'(write-file "{second_path}" ' + f'(delegate-task-to-openclaw-agent "Reply with exactly: {second_marker}"))' }), + ("send", { "content": f"Both delegations saved {c.run_id}" }) + ]), ) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within timeout") @@ -252,12 +252,12 @@ def test_delegate_stays_async_under_a_slow_gateway_mock(llm, comm, gateway): prompt = make_prompt(c.run_id, "Delegate a long task.") llm.set_answer( request=prompt, - response=( - f'(metta (write-file "{out_path}" ' + response=([ + ("metta", { "sexpression": f'(write-file "{out_path}" ' f'(delegate-task-to-openclaw-agent ' - f'"OCGW_SLEEP:{SLOW_GATEWAY_SECONDS} Reply with exactly: SLOW-{c.run_id}")))\n' - f'(send "Long delegation started {c.run_id}")' - ), + f'"OCGW_SLEEP:{SLOW_GATEWAY_SECONDS} Reply with exactly: SLOW-{c.run_id}"))' }), + ("send", { "content": f"Long delegation started {c.run_id}" }) + ]), ) started = time.time() if not comm.send_message(prompt): @@ -277,7 +277,7 @@ def test_delegate_stays_async_under_a_slow_gateway_mock(llm, comm, gateway): c.step("an unrelated prompt is answered while the delegation is still pending") second_id = c.run_id + 1 second = make_prompt(second_id, "Answer with the marker.") - llm.set_answer(request=second, response=f'(send "STILL-ALIVE-{c.run_id}")') + llm.set_answer(request=second, response=[("send", { "content": f"STILL-ALIVE-{c.run_id}" })]) if not comm.send_message(second): c.fail("comm", "could not deliver the second prompt within timeout") alive = wait_for_skill_call(second_id, "send", timeout=ACK_BUDGET_SECONDS, @@ -321,11 +321,11 @@ def test_delegate_reports_gateway_rejection_mock(llm, comm, gateway): prompt = make_prompt(c.run_id, "Delegate a task that will be refused.") llm.set_answer( request=prompt, - response=( - f'(metta (write-file "{out_path}" ' - f'(delegate-task-to-openclaw-agent "OCGW_UNAUTHORIZED {c.run_id}")))\n' - f'(send "Refused delegation checked {c.run_id}")' - ), + response=([ + ("metta", { "sexpression": f'(write-file "{out_path}" ' + f'(delegate-task-to-openclaw-agent "OCGW_UNAUTHORIZED {c.run_id}"))' }), + ("send", { "content": f"Refused delegation checked {c.run_id}" }) + ]), ) if not comm.send_message(prompt): c.fail("comm", "could not deliver the prompt within timeout") @@ -367,11 +367,11 @@ def test_delegate_retries_a_starting_gateway_mock(llm, comm, gateway): prompt = make_prompt(c.run_id, "Delegate a task to a starting Gateway.") llm.set_answer( request=prompt, - response=( - f'(metta (write-file "{out_path}" (delegate-task-to-openclaw-agent ' - f'"OCGW_503:2 Reply with exactly: {marker}")))\n' - f'(send "Retry delegation started {c.run_id}")' - ), + response=([ + ("metta", { "sexpression": f'(write-file "{out_path}" (delegate-task-to-openclaw-agent ' + f'"OCGW_503:2 Reply with exactly: {marker}"))' }), + ("send", { "content": f"Retry delegation started {c.run_id}" }) + ]), ) if not comm.send_message(prompt): c.fail("comm", "could not deliver the prompt within timeout") @@ -412,11 +412,11 @@ def test_delegate_reports_a_reply_without_text_mock(llm, comm, gateway): prompt = make_prompt(c.run_id, "Delegate a task answered without text.") llm.set_answer( request=prompt, - response=( - f'(metta (write-file "{out_path}" ' - f'(delegate-task-to-openclaw-agent "OCGW_NOTEXT {c.run_id}")))\n' - f'(send "Empty reply checked {c.run_id}")' - ), + response=([ + ("metta", { "sexpression": f'(write-file "{out_path}" ' + f'(delegate-task-to-openclaw-agent "OCGW_NOTEXT {c.run_id}"))' }), + ("send", { "content": f"Empty reply checked {c.run_id}" }) + ]), ) if not comm.send_message(prompt): c.fail("comm", "could not deliver the prompt within timeout") @@ -444,10 +444,10 @@ def test_delegation_is_authenticated_by_the_proxy_mock(llm, comm, gateway): prompt = make_prompt(c.run_id, "Delegate a task.") llm.set_answer( request=prompt, - response=( - f'(delegate-task-to-openclaw-agent "Reply with exactly: TOKEN-{c.run_id}")\n' - f'(send "Token delegation sent {c.run_id}")' - ), + response=([ + ("delegate-task-to-openclaw-agent", { "task": f"Reply with exactly: TOKEN-{c.run_id}" }), + ("send", { "content": f"Token delegation sent {c.run_id}" }) + ]), ) if not comm.send_message(prompt): c.fail("comm", "could not deliver the prompt within timeout") diff --git a/Autotests/mock/test_pin_invisible_within_iteration_mock.py b/Autotests/mock/test_pin_invisible_within_iteration_mock.py index 8017b3d2..49bf2dd2 100644 --- a/Autotests/mock/test_pin_invisible_within_iteration_mock.py +++ b/Autotests/mock/test_pin_invisible_within_iteration_mock.py @@ -5,9 +5,9 @@ addToHistory at the END of the iteration — only enters HISTORY at the next prompt-build. -Verification reads the docker log: the CHARS_SENT line that carries +Verification reads the docker log: the REQUEST line that carries the PROMPT for the iteration containing our pin must NOT contain the -pin's unique marker; the NEXT CHARS_SENT line (next iteration) must. +pin's unique marker; the NEXT REQUEST line (next iteration) must. Run: pytest test_pin_invisible_within_iteration_mock.py -s @@ -32,7 +32,7 @@ def docker_logs(): def chars_sent_lines(): return [ ln for ln in docker_logs().split("\n") - if "CHARS_SENT:" in ln + if "REQUEST:" in ln ] @@ -45,11 +45,11 @@ def test_pin_invisible_within_iteration_mock(llm, comm): c.add_cleanup_marker(marker) baseline_count = len(chars_sent_lines()) - c.ok("docker log baseline", f"{baseline_count} CHARS_SENT lines so far") + c.ok("docker log baseline", f"{baseline_count} REQUEST lines so far") c.step("send a prompt; mock answers with (pin ...) + (send ...)") # IMPORTANT: marker must NOT appear in HUMAN_MESSAGE. Otherwise it - # ends up in CHARS_SENT for the current iteration via the prompt + # ends up in REQUEST for the current iteration via the prompt # body itself, defeating the within-iteration visibility check. # We ask the agent to "track progress with a short code"; the mock # answer supplies the actual marker only inside (pin ...). @@ -60,7 +60,7 @@ def test_pin_invisible_within_iteration_mock(llm, comm): ) llm.set_answer( prompt, - f'(pin "{marker}") (send "Pinned a progress code.")', + [("pin", { "message": f"{marker}" })] ) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within 60s") @@ -80,7 +80,7 @@ def test_pin_invisible_within_iteration_mock(llm, comm): c.step("wait so the next iteration definitely starts and logs its PROMPT") time.sleep(30) - c.step("split CHARS_SENT lines into before-pin and after-pin") + c.step("split REQUEST lines into before-pin and after-pin") all_lines = chars_sent_lines() # Find the iteration that received our HUMAN_MESSAGE (it carries # REQ-{run_id} in the prompt). @@ -91,7 +91,7 @@ def test_pin_invisible_within_iteration_mock(llm, comm): break if owning_idx is None: c.fail("locate iteration", - f"no CHARS_SENT line carries REQ-{c.run_id} — " + f"no REQUEST line carries REQ-{c.run_id} — " f"prompt never reached agent loop") c.ok("locate iteration", f"PROMPT carrying our REQ at log index {owning_idx} " @@ -112,7 +112,7 @@ def test_pin_invisible_within_iteration_mock(llm, comm): f"marker not in own iteration's PROMPT — as expected") c.step( - "verify SOME later CHARS_SENT line DOES carry the marker " + "verify SOME later REQUEST line DOES carry the marker " "(pin became visible on a subsequent iteration)" ) later_hits = [ @@ -122,7 +122,7 @@ def test_pin_invisible_within_iteration_mock(llm, comm): if not later_hits: c.fail( "pin appears later", - f"no later CHARS_SENT contains {marker!r}; pin did not propagate " + f"no later REQUEST contains {marker!r}; pin did not propagate " f"to subsequent HISTORY", ) c.ok("pin appears later", diff --git a/Autotests/mock/test_prompt_missing_files_mock.py b/Autotests/mock/test_prompt_missing_files_mock.py index 1873e848..1dce4ed6 100644 --- a/Autotests/mock/test_prompt_missing_files_mock.py +++ b/Autotests/mock/test_prompt_missing_files_mock.py @@ -36,7 +36,9 @@ def test_missing_default_prompt_mock(llm, comm): c.step("Send message to agent") prompt = make_prompt(c.run_id, "Testing missing default prompt.") - llm.set_answer(prompt, f'(send "Prompt tested {c.run_id}")') + llm.set_answer(prompt, [ + ("send", { "content": f"Prompt tested {c.run_id}" }) + ]) comm.send_message(prompt) c.step("Verify agent survived and responded") @@ -62,7 +64,7 @@ def test_missing_provider_prompt_mock(llm, comm): c.step("Send message to agent") prompt = make_prompt(c.run_id, "Testing fallback to default prompt.") - llm.set_answer(prompt, f'(send "Fallback tested {c.run_id}")') + llm.set_answer(prompt, [("send", { "content": f"Fallback tested {c.run_id}" })]) comm.send_message(prompt) c.step("Verify agent successfully used fallback prompt") diff --git a/Autotests/mock/test_run_create_dirs_mock.py b/Autotests/mock/test_run_create_dirs_mock.py index 8c13521e..a1b7cd12 100644 --- a/Autotests/mock/test_run_create_dirs_mock.py +++ b/Autotests/mock/test_run_create_dirs_mock.py @@ -46,9 +46,9 @@ def test_run_create_dirs_mock(llm, comm): mkdir_args = " ".join(f"{TARGET_DIR}/{d}" for d in EXPECTED_DIRS) llm.set_answer( prompt, - f'(write-file "{SCRIPT_PATH}" "#!/bin/bash\\nmkdir -p {mkdir_args}\\n") ' - f'(shell "chmod +x {SCRIPT_PATH}") ' - f'(shell "sh {SCRIPT_PATH}")', + [("write-file", { "filename": f"{SCRIPT_PATH}", "content": f"#!/bin/bash\\nmkdir -p {mkdir_args}\\n" }), + ("shell", { "cmd": f"chmod +x {SCRIPT_PATH}" }), + ("shell", { "cmd": f"sh {SCRIPT_PATH}" })] ) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within 60s") diff --git a/Autotests/mock/test_run_error_script_mock.py b/Autotests/mock/test_run_error_script_mock.py index d9f7372b..0dcb186f 100644 --- a/Autotests/mock/test_run_error_script_mock.py +++ b/Autotests/mock/test_run_error_script_mock.py @@ -54,7 +54,7 @@ def test_run_error_script_mock(llm, comm): ) llm.set_answer( prompt, - f'(shell "sh {SCRIPT_FILE} > {OUTPUT_FILE} 2>&1")', + [("shell", { "cmd": f"sh {SCRIPT_FILE} > {OUTPUT_FILE} 2>&1" })] ) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within 60s") diff --git a/Autotests/mock/test_run_repeated_mock.py b/Autotests/mock/test_run_repeated_mock.py index ecfff62f..bf904aa4 100644 --- a/Autotests/mock/test_run_repeated_mock.py +++ b/Autotests/mock/test_run_repeated_mock.py @@ -48,7 +48,7 @@ def test_run_repeated_mock(llm, comm): f"a row. The script appends a date line to {OUTPUT_FILE} each " "time it runs.", ) - repeated = " ".join(f'(shell "sh {SCRIPT_FILE}")' for _ in range(EXPECTED_RUNS)) + repeated = [("shell", { "cmd": f"sh {SCRIPT_FILE}" }) for _ in range(EXPECTED_RUNS)] llm.set_answer(prompt, repeated) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within 60s") diff --git a/Autotests/mock/test_search_basic_mock.py b/Autotests/mock/test_search_basic_mock.py index f90f5cc4..bf2ddc10 100644 --- a/Autotests/mock/test_search_basic_mock.py +++ b/Autotests/mock/test_search_basic_mock.py @@ -37,7 +37,7 @@ def test_search_basic_mock(llm, comm): c.run_id, "What is SingularityNet? Search the web and give me a short description.", ) - llm.set_answer(prompt, f'(send "{SINGULARITYNET_DESCRIPTION}")') + llm.set_answer(prompt, [("send", { "content": f"{SINGULARITYNET_DESCRIPTION}" })]) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within 60s") c.ok("comm", f"run-id={c.run_id}") diff --git a/Autotests/mock/test_search_invalid_mock.py b/Autotests/mock/test_search_invalid_mock.py index a33ad771..933c6443 100644 --- a/Autotests/mock/test_search_invalid_mock.py +++ b/Autotests/mock/test_search_invalid_mock.py @@ -43,8 +43,8 @@ def test_search_invalid_mock(llm, comm): ) llm.set_answer( prompt, - f'(send "No results found for {GIBBERISH}. The string appears to ' - f'be gibberish — no meaningful matches.")', + [("send", { "content": f'No results found for {GIBBERISH}. The string appears to ' + f'be gibberish — no meaningful matches.' })], ) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within 60s") diff --git a/Autotests/mock/test_search_weather_mock.py b/Autotests/mock/test_search_weather_mock.py index fcb8ce81..8e4a6898 100644 --- a/Autotests/mock/test_search_weather_mock.py +++ b/Autotests/mock/test_search_weather_mock.py @@ -34,7 +34,7 @@ def test_search_weather_mock(llm, comm): mocked_reply = ( f"Current weather in Valencia, Spain: about {REF_TEMP_C:.1f}°C." ) - llm.set_answer(prompt, f'(send "{mocked_reply}")') + llm.set_answer(prompt, [("send", { "content": f"{mocked_reply}" })]) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within 60s") c.ok("comm", f"run-id={c.run_id}") diff --git a/Autotests/mock/test_skill_episodes_mock.py b/Autotests/mock/test_skill_episodes_mock.py index 595a3833..90e785c3 100644 --- a/Autotests/mock/test_skill_episodes_mock.py +++ b/Autotests/mock/test_skill_episodes_mock.py @@ -41,7 +41,7 @@ def test_skill_episodes_mock(llm, comm): f"the keyword {marker} from me. No need to remember it — just " f"reply once.", ) - llm.set_answer(seed_prompt, f'(send "Acknowledged keyword {marker}.")') + llm.set_answer(seed_prompt, [("send", { "content": f"Acknowledged keyword {marker}." })]) if not comm.send_message(seed_prompt): c.fail("comm-seed", "could not deliver seed prompt within 60s") c.ok("comm-seed", f"run-id={seed_id}, time={seed_time:%H:%M:%S}") @@ -70,7 +70,8 @@ def test_skill_episodes_mock(llm, comm): ) llm.set_answer( recall_prompt, - f'(episodes "{time_str}") (send "The unique keyword was {marker}.")', + [("episodes", { "timestamp": f"{time_str}" }), + ("send", { "content": f"The unique keyword was {marker}." })] ) if not comm.send_message(recall_prompt): c.fail("comm-recall", "could not deliver recall prompt within 60s") diff --git a/Autotests/mock/test_skill_metta_mock.py b/Autotests/mock/test_skill_metta_mock.py index e3e869c8..f1c864a4 100644 --- a/Autotests/mock/test_skill_metta_mock.py +++ b/Autotests/mock/test_skill_metta_mock.py @@ -34,7 +34,8 @@ def test_skill_metta_mock(llm, comm): # concrete number is communicated back. llm.set_answer( prompt, - '(metta "(+ 2 2)") (send "The metta skill evaluated (+ 2 2) and returned 4.")', + [("metta", { "sexpression": "(+ 2 2)" }), + ("send", { "content": "The metta skill evaluated (+ 2 2) and returned 4." })], ) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within 60s") diff --git a/Autotests/mock/test_skill_pin_mock.py b/Autotests/mock/test_skill_pin_mock.py index 2ee8478d..7bc989c5 100644 --- a/Autotests/mock/test_skill_pin_mock.py +++ b/Autotests/mock/test_skill_pin_mock.py @@ -30,8 +30,8 @@ def test_skill_pin_mock(llm, comm): ) llm.set_answer( prompt, - '(pin "Server restart progress: alpha done; beta and gamma pending.") ' - '(send "Tracking: alpha done, beta and gamma pending.")', + [("pin", { "message": "Server restart progress: alpha done; beta and gamma pending." }), + ("send", { "content": "Tracking: alpha done, beta and gamma pending." })] ) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within 60s") diff --git a/Autotests/mock/test_skill_query_mock.py b/Autotests/mock/test_skill_query_mock.py index cf850632..fc5e1fb1 100644 --- a/Autotests/mock/test_skill_query_mock.py +++ b/Autotests/mock/test_skill_query_mock.py @@ -41,8 +41,10 @@ def test_skill_query_mock(llm, comm): ) llm.set_answer( seed_prompt, - f'(remember "My favorite color is {secret_color}.") ' - f'(send "Stored: favorite colour is {secret_color}.")', + [ + ("remember", { "content": f"My favorite color is {secret_color}." }), + ("send", { "content": f"Stored: favorite colour is {secret_color}." }) + ] ) if not comm.send_message(seed_prompt): c.fail("comm-seed", "could not deliver seed prompt within 60s") @@ -77,8 +79,8 @@ def has_color(arg): ) llm.set_answer( recall_prompt, - f'(query "favorite color") ' - f'(send "Your favorite color is {secret_color}.")', + [("query", { "content": f"favorite color" }), + ("send", { "content": f"Your favorite color is {secret_color}." })] ) if not comm.send_message(recall_prompt): c.fail("comm-recall", "could not deliver recall prompt within 60s") diff --git a/Autotests/mock/test_transition_episodes_after_eviction_mock.py b/Autotests/mock/test_transition_episodes_after_eviction_mock.py index 44d99bfa..7a98f870 100644 --- a/Autotests/mock/test_transition_episodes_after_eviction_mock.py +++ b/Autotests/mock/test_transition_episodes_after_eviction_mock.py @@ -47,7 +47,7 @@ def test_transition_episodes_after_eviction_mock(llm, comm): c.run_id, f"Send back exactly this token in a single send: {beacon_marker}", ) - llm.set_answer(prompt1, f'(send "{beacon_marker}")') + llm.set_answer(prompt1, [("send", { "content": f"{beacon_marker}" })]) if not comm.send_message(prompt1): c.fail("comm-1", "could not deliver seed prompt within 60s") c.ok("comm-1", @@ -74,7 +74,7 @@ def test_transition_episodes_after_eviction_mock(llm, comm): ) llm.set_answer( prompt2, - f'(remember "{padding_body}") (send "padded")', + [("remember", { "content": f"{padding_body}" })] ) if not comm.send_message(prompt2): c.fail("comm-2", "could not deliver padding prompt within 60s") @@ -109,7 +109,8 @@ def test_transition_episodes_after_eviction_mock(llm, comm): ) llm.set_answer( prompt3, - f'(episodes "{seed_ts_str}") (send "Recalled {beacon_marker}.")', + [("episodes", { "timestamp": f"{seed_ts_str}" }), + ("send", { "content": f"Recalled {beacon_marker}." })] ) if not comm.send_message(prompt3): c.fail("comm-3", "could not deliver recall prompt within 60s") diff --git a/Autotests/mock/test_transition_metta_to_remember_mock.py b/Autotests/mock/test_transition_metta_to_remember_mock.py index ffe92b57..1f77da2e 100644 --- a/Autotests/mock/test_transition_metta_to_remember_mock.py +++ b/Autotests/mock/test_transition_metta_to_remember_mock.py @@ -57,16 +57,20 @@ def test_transition_metta_to_remember_mock(llm, comm): f"memory tagged '{conclusion_marker}'.", ) metta_call = ( - '(metta "(|- ((--> sam friend) (stv 1.0 0.9)) ' - '((--> garfield animal) (stv 1.0 0.9)))")' + "(|- ((--> sam friend) (stv 1.0 0.9)) " + "((--> garfield animal) (stv 1.0 0.9)))" ) remember_call = ( - f'(remember "{conclusion_marker}: Sam is friend of an animal ' - '(derived via NAL inheritance).")' + f"{conclusion_marker}: Sam is friend of an animal " + "(derived via NAL inheritance)." ) llm.set_answer( prompt, - f'{metta_call} {remember_call} (send "Reasoned and remembered.")', + [ + ("metta", { "sexpression": metta_call }), + ("remember", { "content": remember_call }), + ("send", { "content": "Reasoned and remembered." }) + ] ) if not comm.send_message(prompt): c.fail("comm", "could not deliver prompt within 60s") diff --git a/Autotests/mock/test_transition_pin_to_remember_mock.py b/Autotests/mock/test_transition_pin_to_remember_mock.py index 8515e53a..0c2be22f 100644 --- a/Autotests/mock/test_transition_pin_to_remember_mock.py +++ b/Autotests/mock/test_transition_pin_to_remember_mock.py @@ -56,8 +56,8 @@ def test_transition_pin_to_remember_mock(llm, comm): ) llm.set_answer( prompt1, - f'(pin "{marker}: candidates A, B, C") ' - f'(send "Pinned {marker}: A, B, C.")', + [("pin", { "message": f"{marker}: candidates A, B, C" }), + ("send", { "content": f"Pinned {marker}: A, B, C." })] ) if not comm.send_message(prompt1): c.fail("comm-1", "could not deliver turn 1 prompt within 60s") @@ -90,8 +90,8 @@ def test_transition_pin_to_remember_mock(llm, comm): ) llm.set_answer( prompt2, - f'(remember "{marker}: candidates A, B, C") ' - f'(send "Committed {marker} to long-term memory.")', + [("remember", { "content": f"{marker}: candidates A, B, C" }), + ("send", { "content": f"Committed {marker} to long-term memory." })] ) if not comm.send_message(prompt2): c.fail("comm-2", "could not deliver turn 2 prompt within 60s") diff --git a/Autotests/mock/test_workflow_plugin_mock.py b/Autotests/mock/test_workflow_plugin_mock.py index 41295a02..ba057510 100644 --- a/Autotests/mock/test_workflow_plugin_mock.py +++ b/Autotests/mock/test_workflow_plugin_mock.py @@ -73,7 +73,9 @@ def test_load_and_skill(self, llm, comm): "Demonstrate the workflow plugin: load the test-workflow " "instructions.", ) - llm.set_answer(prompt1, f'(workflow-load-instructions "{WORKFLOW}")') + llm.set_answer(prompt1, + [("workflow-load-instructions", { "workflow_name": f"{WORKFLOW}" })] + ) if not comm.send_message(prompt1): c.fail("comm-1", "could not deliver turn 1 prompt within 60s") loaded = _recv_contains(comm, f"Loaded workflow: {WORKFLOW}", timeout=60) @@ -89,7 +91,8 @@ def test_load_and_skill(self, llm, comm): prompt2 = make_prompt(skill_id, "Continue the workflow: perform step 1.") llm.set_answer( prompt2, - f'({WORKFLOW_SKILL} "{DEMO_MESSAGE}") (workflow-unload-instructions)', + [(f"{WORKFLOW_SKILL}", { "message": f"{DEMO_MESSAGE}" }), + ("workflow-unload-instructions", {})] ) if not comm.send_message(prompt2): c.fail("comm-2", "could not deliver turn 2 prompt within 60s") @@ -116,7 +119,7 @@ def test_unload_removes_skill(self, llm, comm): c.step("turn 1: load the test-workflow") prompt1 = make_prompt(c.run_id, "Load the test-workflow instructions.") - llm.set_answer(prompt1, f'(workflow-load-instructions "{WORKFLOW}")') + llm.set_answer(prompt1, [("workflow-load-instructions", { "workflow_name": f"{WORKFLOW}" })]) if not comm.send_message(prompt1): c.fail("comm-1", "could not deliver turn 1 prompt within 60s") if _recv_contains(comm, f"Loaded workflow: {WORKFLOW}", timeout=60) is None: @@ -127,7 +130,7 @@ def test_unload_removes_skill(self, llm, comm): unload_id = c.run_id + 1 time.sleep(5) prompt2 = make_prompt(unload_id, "The workflow is done, unload it now.") - llm.set_answer(prompt2, "(workflow-unload-instructions)") + llm.set_answer(prompt2, [("workflow-unload-instructions", {})]) if not comm.send_message(prompt2): c.fail("comm-2", "could not deliver turn 2 prompt within 60s") time.sleep(12) # let the unload turn complete @@ -139,7 +142,7 @@ def test_unload_removes_skill(self, llm, comm): marker = f"gone-{c.run_id}" time.sleep(2) prompt3 = make_prompt(gone_id, "Please run the workflow step again.") - llm.set_answer(prompt3, f'({WORKFLOW_SKILL} "{marker}")') + llm.set_answer(prompt3, [(f"{WORKFLOW_SKILL}", { "message": f"{marker}" })]) if not comm.send_message(prompt3): c.fail("comm-3", "could not deliver turn 3 prompt within 60s") still = _recv_contains(comm, marker, timeout=25) @@ -161,7 +164,7 @@ def test_research_workflow(self, llm, comm): c.step("turn 1: load research-workflow") prompt1 = make_prompt(c.run_id, f"Load the {RESEARCH_WORKFLOW} instructions.") - llm.set_answer(prompt1, f'(workflow-load-instructions "{RESEARCH_WORKFLOW}")') + llm.set_answer(prompt1, [("workflow-load-instructions", { "workflow_name": f"{RESEARCH_WORKFLOW}" })]) if not comm.send_message(prompt1): c.fail("comm-1", "could not deliver turn 1 prompt within 60s") if _recv_contains(comm, f"Loaded workflow: {RESEARCH_WORKFLOW}", timeout=60) is None: @@ -173,7 +176,10 @@ def test_research_workflow(self, llm, comm): topic = f"iris via mock {c.run_id}" time.sleep(5) prompt2 = make_prompt(start_id, "Start the research project.") - llm.set_answer(prompt2, f'(research-start "{RESEARCH_NAME}" "{topic}")') + llm.set_answer(prompt2, [("research-start", { + "research_name": f"{RESEARCH_NAME}", + "topic": f"{topic}" + })]) if not comm.send_message(prompt2): c.fail("comm-2", "could not deliver turn 2 prompt within 60s") created = _recv_contains(comm, "Created project:", timeout=60) diff --git a/Autotests/mock_memory/README.md b/Autotests/mock_memory/README.md index fa5a6dd3..d3ae51cf 100644 --- a/Autotests/mock_memory/README.md +++ b/Autotests/mock_memory/README.md @@ -114,8 +114,8 @@ iteration N+1. - Mock answer: `(pin "") (send "Pinned a progress code.")`. The marker is intentionally not placed in the HUMAN_MESSAGE body so it cannot leak into the current iteration's PROMPT via that path. -- Checks (via docker logs): the CHARS_SENT line for the iteration that carries REQ-`` - must not contain the marker; at least one later CHARS_SENT line must contain it. +- Checks (via docker logs): the REQUEST line for the iteration that carries REQ-`` + must not contain the marker; at least one later REQUEST line must contain it. ### 3. test_memory_history_byte_window_truncation_mock.py @@ -173,5 +173,5 @@ assembled PROMPT for iteration N+1. - Mock answer: `(metta "(quote )") (send "computed")`. The sentinel is placed inside the metta expression so it can be located in the next iteration's PROMPT. -- Checks (via docker logs): the CHARS_SENT line that follows the one carrying REQ-`` +- Checks (via docker logs): the REQUEST line that follows the one carrying REQ-`` contains the LAST_SKILL_USE_RESULTS marker and the sentinel string. diff --git a/Autotests/mock_slack/README.md b/Autotests/mock_slack/README.md index 36ba98e2..ba83c02f 100644 --- a/Autotests/mock_slack/README.md +++ b/Autotests/mock_slack/README.md @@ -129,10 +129,10 @@ Notes: - `SL_CHANNEL_ID` is the shared channel both bots live in. - `TEST_SERVER_IP=172.17.0.1` is the host's docker-bridge address used by the mock LLM provider. It must be set even for the Slack channel, because `provider=Test` reads it. -Wait until the agent loop is up. The first runtime `CHARS_SENT:` line (with a byte count after the colon) marks the end of `initChannels` / `initMemory`: +Wait until the agent loop is up. The first runtime `iteration 1` line marks the end of `initChannels` / `initMemory`: ``` -until docker logs omega 2>&1 | grep -qE "CHARS_SENT: [0-9]+"; do sleep 2; done +until docker logs omega 2>&1 | grep -qE "iteration 1"; do sleep 2; done ``` ## 5. Configure the test environment diff --git a/Autotests/mock_slack/test_git_pull_public_slack_mock.py b/Autotests/mock_slack/test_git_pull_public_slack_mock.py index 688a156b..4321fa1f 100644 --- a/Autotests/mock_slack/test_git_pull_public_slack_mock.py +++ b/Autotests/mock_slack/test_git_pull_public_slack_mock.py @@ -56,7 +56,7 @@ def test_git_pull_public_slack_mock(llm, sl): # clone into the path directly. llm.set_answer( prompt, - f'(shell "rm -rf {TARGET_DIR} && git clone {remote} {TARGET_DIR}")', + [("shell", { "cmd": f"rm -rf {TARGET_DIR} && git clone {remote} {TARGET_DIR}" })] ) sl_send_prompt(sl, prompt) c.ok("slack", f"run-id={c.run_id}") diff --git a/Autotests/mock_slack/test_git_push_to_remote_slack_mock.py b/Autotests/mock_slack/test_git_push_to_remote_slack_mock.py index 8c8aa3ff..696e04ec 100644 --- a/Autotests/mock_slack/test_git_push_to_remote_slack_mock.py +++ b/Autotests/mock_slack/test_git_push_to_remote_slack_mock.py @@ -108,7 +108,7 @@ def test_git_push_to_remote_slack_mock(llm, sl): f"git commit -m 'qa run {c.run_id}' && " f"git push -u origin {branch}" ) - llm.set_answer(prompt, f'(shell "{chain}")') + llm.set_answer(prompt, [("shell", { "cmd": f"{chain}" })]) sl_send_prompt(sl, prompt) c.ok("slack", f"run-id={c.run_id}") diff --git a/Autotests/mock_slack/test_memory_history_slack_mock.py b/Autotests/mock_slack/test_memory_history_slack_mock.py index 76565f4d..327ffbf3 100644 --- a/Autotests/mock_slack/test_memory_history_slack_mock.py +++ b/Autotests/mock_slack/test_memory_history_slack_mock.py @@ -39,7 +39,7 @@ def test_memory_history_slack_mock(llm, sl): f"Acknowledge with one short line that you received marker {c.run_id}.", ) ack = f"Marker {c.run_id} received. REQ-{c.run_id} acknowledged." - llm.set_answer(prompt, f'(send "{ack}")') + llm.set_answer(prompt, [("send", { "content": f"{ack}" })]) sl_send_prompt(sl, prompt) c.ok("slack", f"run-id={c.run_id}") diff --git a/Autotests/mock_slack/test_run_repeated_slack_mock.py b/Autotests/mock_slack/test_run_repeated_slack_mock.py index a821d552..8f71bdd6 100644 --- a/Autotests/mock_slack/test_run_repeated_slack_mock.py +++ b/Autotests/mock_slack/test_run_repeated_slack_mock.py @@ -49,7 +49,7 @@ def test_run_repeated_slack_mock(llm, sl): f"a row. The script appends a date line to {OUTPUT_FILE} each " "time it runs.", ) - repeated = " ".join(f'(shell "sh {SCRIPT_FILE}")' for _ in range(EXPECTED_RUNS)) + repeated = [("shell", { "cmd": f"sh {SCRIPT_FILE}" }) for _ in range(EXPECTED_RUNS)] llm.set_answer(prompt, repeated) sl_send_prompt(sl, prompt) c.ok("slack", f"run-id={c.run_id}") diff --git a/Autotests/mock_slack/test_search_basic_slack_mock.py b/Autotests/mock_slack/test_search_basic_slack_mock.py index e3a885d7..875efe3b 100644 --- a/Autotests/mock_slack/test_search_basic_slack_mock.py +++ b/Autotests/mock_slack/test_search_basic_slack_mock.py @@ -37,7 +37,7 @@ def test_search_basic_slack_mock(llm, sl): c.run_id, "What is SingularityNet? Search the web and give me a short description.", ) - llm.set_answer(prompt, f'(send "{SINGULARITYNET_DESCRIPTION}")') + llm.set_answer(prompt, [("send", { "content": f"{SINGULARITYNET_DESCRIPTION}" })]) sl_send_prompt(sl, prompt) c.ok("slack", f"run-id={c.run_id}") diff --git a/Autotests/mock_slack/test_search_invalid_slack_mock.py b/Autotests/mock_slack/test_search_invalid_slack_mock.py index 09bf039a..9c082d09 100644 --- a/Autotests/mock_slack/test_search_invalid_slack_mock.py +++ b/Autotests/mock_slack/test_search_invalid_slack_mock.py @@ -43,8 +43,10 @@ def test_search_invalid_slack_mock(llm, sl): ) llm.set_answer( prompt, - f'(send "No results found for {GIBBERISH}. The string appears to ' - f'be gibberish — no meaningful matches.")', + [ + ("send", { "content": f"No results found for {GIBBERISH}. The string appears to " + "be gibberish — no meaningful matches." }), + ] ) sl_send_prompt(sl, prompt) c.ok("slack", f"run-id={c.run_id}") diff --git a/Autotests/mock_slack/test_search_weather_slack_mock.py b/Autotests/mock_slack/test_search_weather_slack_mock.py index f0951583..031d256b 100644 --- a/Autotests/mock_slack/test_search_weather_slack_mock.py +++ b/Autotests/mock_slack/test_search_weather_slack_mock.py @@ -34,7 +34,7 @@ def test_search_weather_slack_mock(llm, sl): mocked_reply = ( f"Current weather in Valencia, Spain: about {REF_TEMP_C:.1f}В°C." ) - llm.set_answer(prompt, f'(send "{mocked_reply}")') + llm.set_answer(prompt, [("send", { "content": f"{mocked_reply}" })]) sl_send_prompt(sl, prompt) c.ok("slack", f"run-id={c.run_id}") diff --git a/Autotests/mock_slack/test_skill_pin_slack_mock.py b/Autotests/mock_slack/test_skill_pin_slack_mock.py index 9cb4e16a..30c5ad89 100644 --- a/Autotests/mock_slack/test_skill_pin_slack_mock.py +++ b/Autotests/mock_slack/test_skill_pin_slack_mock.py @@ -9,7 +9,8 @@ """ from helpers import ( - Checker, find_skill_calls, make_prompt, wait_for_skill_call, wait_for_skill_match, + Checker, find_skill_calls, make_prompt, wait_for_skill_call, + wait_for_skill_match, ) from slack_helpers import sl_send_prompt @@ -30,8 +31,10 @@ def test_skill_pin_slack_mock(llm, sl): ) llm.set_answer( prompt, - '(pin "Server restart progress: alpha done; beta and gamma pending.") ' - '(send "Tracking: alpha done, beta and gamma pending.")', + [ + ("pin", { "message": "Server restart progress: alpha done; beta and gamma pending." }), + ("send", { "content": "Tracking: alpha done, beta and gamma pending." }) + ] ) sl_send_prompt(sl, prompt) c.ok("slack", f"run-id={c.run_id}") diff --git a/Autotests/mock_slack/test_skill_query_slack_mock.py b/Autotests/mock_slack/test_skill_query_slack_mock.py index c3e00f5b..f1f6ef7b 100644 --- a/Autotests/mock_slack/test_skill_query_slack_mock.py +++ b/Autotests/mock_slack/test_skill_query_slack_mock.py @@ -16,7 +16,8 @@ from helpers import ( - Checker, find_skill_calls, make_prompt, wait_for_skill_call, wait_for_skill_match, + Checker, find_skill_calls, make_prompt, wait_for_skill_call, + wait_for_skill_match, ) from slack_helpers import sl_send_prompt @@ -41,8 +42,10 @@ def test_skill_query_slack_mock(llm, sl): ) llm.set_answer( seed_prompt, - f'(remember "My favorite color is {secret_color}.") ' - f'(send "Stored: favorite colour is {secret_color}.")', + [ + ("remember", { "content": f"My favorite color is {secret_color}." }), + ("send", { "content": f"Stored: favorite colour is {secret_color}."), + ] ) sl_send_prompt(sl, seed_prompt) c.ok("irc-seed", f"run-id={seed_id}") diff --git a/Autotests/mock_websocket/README.md b/Autotests/mock_websocket/README.md index b54e64ef..3d865e6a 100644 --- a/Autotests/mock_websocket/README.md +++ b/Autotests/mock_websocket/README.md @@ -47,7 +47,7 @@ can be started before or after the pytest session. Wait until the agent loop is running: ``` -until docker logs omega 2>&1 | grep -qE "CHARS_SENT: [0-9]+"; do sleep 2; done +until docker logs omega 2>&1 | grep -qE "iteration 1"; do sleep 2; done ``` ## 4. Configure the test environment diff --git a/lib_omega.metta b/lib_omega.metta index e4919d83..5ce6babb 100644 --- a/lib_omega.metta +++ b/lib_omega.metta @@ -5,8 +5,9 @@ !(import! &self (library lib_he)) !(import! &self (library Omega lib_nal)) !(import! &self (library Omega lib_pln)) -!(import! &self (library Omega ./src/helper.py)) +!(import! &self (library Omega ./src/utils.py)) !(import! &self (library Omega ./src/utils)) +!(import! &self (library Omega ./src/helper.py)) !(import! &self (library Omega ./src/config.py)) !(import! &self (library Omega ./src/config)) !(import! &self (library Omega ./providers/lib_llm_ext.py)) diff --git a/plugins/openclaw/openclaw.metta b/plugins/openclaw/openclaw.metta index f045b847..876943f7 100644 --- a/plugins/openclaw/openclaw.metta +++ b/plugins/openclaw/openclaw.metta @@ -25,7 +25,7 @@ (add-skill delegate-task-to-openclaw-agent "Delegate a self-contained task to an external OpenClaw agent in a new separate session. Returns an id immediately, the reply arrives later on its own as an OPENCLAW_RESULT line." - (task_in_quotes)) + (task)) (add-heartbeat-listener openclaw (|-> ($iter) (openclaw-collect-results $iter))))) diff --git a/plugins/workflow/instructions/research-workflow/skill.metta b/plugins/workflow/instructions/research-workflow/skill.metta index 6ac0ba28..b4ee5937 100644 --- a/plugins/workflow/instructions/research-workflow/skill.metta +++ b/plugins/workflow/instructions/research-workflow/skill.metta @@ -1,10 +1,10 @@ ; Declare skills to be added to the agent skills when workflow is loaded and ; removed on unloading workflow -(skill research-start "Create research project folders" (research_name_in_quotes topic_in_quotes)) -(skill research-step "Mark research step done" (research_name_in_quotes step_in_quotes result_in_quotes next_action_in_quotes)) -(skill research-checkpoint "Pause for user approval" (researchname_in_quotes message_in_quotes)) -(skill research-complete "Finish research and unload workflow" (researchname_in_quotes)) +(skill research-start "Create research project folders" (research_name topic)) +(skill research-step "Mark research step done" (research_name step result next_action)) +(skill research-checkpoint "Pause for user approval" (researchname message)) +(skill research-complete "Finish research and unload workflow" (researchname)) (skill load-research-dynamic-instructions " Load a generated file into active context" (researchname_in_quotes filename_in_quotes)) (skill researchDir "Get research directory" ()) @@ -70,8 +70,7 @@ (if (valid-file-or-folder-name $research-name) (progn (send (strings-concat ("[" $research-name "] done: " $step))) - (appendToHistory ((get_time_as_string) (newline) - (strings-concat ("RESEARCH [" $research-name "] done: " $step " — " $result "; next: " $next )) (newline)) ) + (addToHistory (strings-concat ("RESEARCH [" $research-name "] done: " $step " — " $result "; next: " $next )) ) (strings-concat ("Done: " $step))) (send (strings-concat ("Invalid name: " $research-name ". Only a-z, 0-9, - and _ allowed."))) ) @@ -84,9 +83,8 @@ (progn (send (strings-concat ("CHECKPOINT [" $research-name "]\n" $message "\n" "Reply to continue."))) - (appendToHistory ((get_time_as_string) (newline) (strings-concat ("RESEARCH_ACTIVE: " $research-name - " STATUS: WAITING_FOR_USER" - )) (newline))) + (addToHistory (strings-concat ("RESEARCH_ACTIVE: " $research-name + " STATUS: WAITING_FOR_USER"))) "Waiting for user") (send (strings-concat ("Invalid name: " $research-name ". Only a-z, 0-9, - and _ allowed."))) ) @@ -99,8 +97,8 @@ (send (strings-concat ("Research complete: " $research-name "\nResults in " (researchDir) "/" $research-name "/" "\nWorkflow unloaded from context."))) - (appendToHistory ((get_time_as_string) (newline) (strings-concat ("RESEARCH [" $research-name "] COMPLETED")) (newline)) ) + (addToHistory (strings-concat ("RESEARCH [" $research-name "] COMPLETED"))) "Research complete, workflow unloaded") (send (strings-concat ("Invalid name: " $research-name ". Only a-z, 0-9, - and _ allowed."))) ) -) \ No newline at end of file +) diff --git a/plugins/workflow/instructions/test-workflow/skill.metta b/plugins/workflow/instructions/test-workflow/skill.metta index 57b176c1..02bd63a6 100644 --- a/plugins/workflow/instructions/test-workflow/skill.metta +++ b/plugins/workflow/instructions/test-workflow/skill.metta @@ -1,4 +1,4 @@ -(skill test-skill "Test skill to demonstrate workflow by sending message to the user" (message_in_quotes)) +(skill test-skill "Test skill to demonstrate workflow by sending message to the user" (message)) (= (test-skill $message) (send $message)) diff --git a/plugins/workflow/workflow.metta b/plugins/workflow/workflow.metta index 07a20420..a98e592a 100644 --- a/plugins/workflow/workflow.metta +++ b/plugins/workflow/workflow.metta @@ -15,7 +15,7 @@ (if (exists-directory (pluginWorkflowMemoryDir)) (progn (log INFO "workflow-plugin" "Adding plugin's skills") - (add-skill workflow-load-instructions "Load an existing workflow instructions into the active context" (workflow_name_in_quotes)) + (add-skill workflow-load-instructions "Load an existing workflow instructions into the active context" (workflow_name)) (add-skill workflow-unload-instructions "Unload active workflow from the active context" ()) (let $workflows (join (newline) (cons-atom "Available workflows: " (workflow-list-available))) (add-prompt-extension workflows-available $workflows)) @@ -54,19 +54,23 @@ (progn (add-prompt-extension workflow_active_instructions (active-instructions $content)) (add-workflow-skills $name) + (change-state! &workflow_active_name $name) (send (strings-concat ("Loaded workflow: " $name ". Instructions active in context until completion."))) - (appendToHistory ((get_time_as_string) (newline) - (strings-concat ("Workflow skills loaded: " $name)) (newline))) - )))))) + (addToHistory (strings-concat ("Workflow skills loaded: " $name))) + )))))) (= (workflow-unload-instructions) - (progn (remove-prompt-extension workflow_active_instructions) + (let $name (get-state &workflow_active_name) + (if (== $name "") + (send "No active workflow is loaded") + (progn + (remove-prompt-extension workflow_active_instructions) (unload-workflow-skills) + (change-state! &workflow_active_name "") (send (strings-concat ("Unloaded workflow: " $name))) - (appendToHistory ((get_time_as_string) (newline) - (strings-concat ("Workflow skills unloaded: " $name)) (newline))) - )) + (addToHistory (strings-concat ("Workflow skills unloaded: " $name))) + )))) (= (active-instructions $content) (strings-concat (" ACTIVE_WORKFLOW_INSTRUCTIONS: " $content @@ -134,4 +138,4 @@ $rest)) ) ) - ) \ No newline at end of file + ) diff --git a/providers/asione.py b/providers/asione.py index edd9f8c6..68d14508 100644 --- a/providers/asione.py +++ b/providers/asione.py @@ -2,7 +2,7 @@ import providers from src.logger import get_logger from config import config_get_by_key -from typing import Dict, Any +from typing import Any logger = get_logger(__name__) @@ -20,8 +20,8 @@ def start(self) -> None: def stop(self) -> None: self.delegate.stop() - def chat(self, prompt: str, max_tokens: int = 6000, reasoning_mode: str = "medium") -> str: - return self.delegate.chat(prompt, max_tokens, reasoning_mode) + def chat(self, args: providers.LLMRequest) -> providers.LLMResponse: + return self.delegate.chat(args) def loadOmegaPlugin(): providers.registerLLMProvider("ASIOne", ASIOneProvider()) @@ -29,19 +29,10 @@ def loadOmegaPlugin(): class ASIOneProviderImpl(llm.AIProvider): """Lazy AI provider with on-demand initialization.""" - def prepare_args(self, content: str, max_tokens: int = 6000, - reasoning: str = "medium", **kwargs) -> Dict[str, Any]: - sysmsg, usermsg = llm._split_system_user(content) - return { - "model": self._model_name, - "messages": [ - {"role": "system", "content": sysmsg}, - {"role": "user", "content": usermsg} - ], - "max_tokens": max_tokens, - "extra_body": { - "enable_thinking": True, - "thinking_budget": 6000 - }, - **kwargs + def convert_request(self, request: providers.LLMRequest) -> dict[str, Any]: + result = super().convert_request(request) + result["extra_body"] = { + "enable_thinking": True, + "thinking_budget": 6000 } + return result diff --git a/providers/lib_llm_ext.py b/providers/lib_llm_ext.py index 1d4362e1..2b39b289 100644 --- a/providers/lib_llm_ext.py +++ b/providers/lib_llm_ext.py @@ -1,7 +1,9 @@ import os, hashlib import openai +from providers import * from typing import Optional, Tuple, Dict, Any from config import config_get_by_key +import json PROMPT_DELIMITER = ":-:-:-:" @@ -10,8 +12,8 @@ logger = get_logger(__name__) -def _log_raw(provider: str, model: str, raw: str) -> None: - logger.debug(f"[LLM_RAW] provider={provider} model={model} chars={len(raw or '')} raw={raw!r}") +def _log_raw(kind, provider: str, model: str, raw: Dict) -> None: + logger.debug(f"[{kind}] provider={provider} model={model} raw={raw!r}") def _split_system_user(content: str) -> Tuple[str, str]: """ @@ -56,7 +58,7 @@ def __init__(self, name: str): def name(self) -> str: return self._name - def chat(self, content: str, max_tokens: int = 6000, reasoning: str = "medium", **kwargs) -> str: + def chat(self, request: LLMRequest) -> LLMResponse: raise NotImplementedError @property @@ -102,30 +104,69 @@ def is_available(self) -> bool: """Check if provider is configured (without initializing).""" return bool(config_get_by_key("GATEWAY_URL")) or bool(os.environ.get(self._var_name)) - def _build_messages(self, content: str): - sysmsg, usermsg = _split_system_user(content) + def convert_message(self, message: LLMMessage) -> Dict: + result = { "role": message.role, "content": message.content } + if isinstance(message, LLMToolCallResponseMessage): + result["tool_call_id"] = message.callid + if isinstance(message, LLMToolCallMessage): + result["tool_calls"] = [self.convert_tool_call(call) for call in message.calls] + return result - if sysmsg: - return [ - {"role": "system", "content": sysmsg}, - {"role": "user", "content": usermsg}, - ] + def convert_tool_call(self, call: LLMToolCall) -> Dict: + return { + "type": "function", + "id": call.id, + "function": { + "name": call.name, + "arguments": json.dumps(call.arguments) + } + } - return [{"role": "user", "content": usermsg}] + def convert_tool(self, tool: LLMTool) -> Dict: + return { + "type": "function", + "function": { + "name": tool.name, + "description": tool.description, + "parameters": { + "type": "object", + "properties": { param.name: { "type": "string" } for param in tool.parameters }, + "required": [ param.name for param in tool.parameters ] + } + } + } - def prepare_args(self, content: str, max_tokens: int = 6000, - reasoning: str = "medium", **kwargs) -> Dict[str, Any]: + def convert_request(self, request: LLMRequest) -> Dict[str, Any]: return { "model": self._model_name, - "messages": self._build_messages(content), - "max_tokens": max_tokens, - **kwargs + "messages": [self.convert_message(msg) for msg in request.messages], + "max_tokens": request.max_tokens, + "tools": [self.convert_tool(tool) for tool in request.tools], + "tool_choice": "required", } - def extract_raw_response(self, response): - return response.choices[0].message.content or "" + def convert_response(self, raw): + response = LLMResponse() - def chat(self, content: str, max_tokens: int = 6000, reasoning: str = "medium", **kwargs) -> str: + message = raw.choices[0].message + if not message.tool_calls: + return response + + for tool_call in message.tool_calls: + tc = LLMToolCall().with_name(tool_call.function.name).with_id(tool_call.id) + try: + arguments = json.loads(tool_call.function.arguments) + except json.JSONDecodeError as error: + response.add_tool_call(tc.with_error(f"Invalid tool arguments from model: {error}")) + else: + if isinstance(arguments, dict): + response.add_tool_call(tc.with_arguments(arguments)) + else: + response.add_tool_call(tc.with_error("Tool arguments must be a JSON object")) + + return response + + def chat(self, request: LLMRequest) -> LLMResponse: """Send chat request, initializing client if needed.""" self._ensure_client() @@ -133,20 +174,15 @@ def chat(self, content: str, max_tokens: int = 6000, reasoning: str = "medium", raise RuntimeError(f"{self.name} not configured (set {self._var_name})") try: - kwargs = self.prepare_args(content, max_tokens, reasoning, **kwargs) - response = self._client.chat.completions.create(**kwargs) - raw = self.extract_raw_response(response) - _log_raw(self._name, self._model_name, raw) - resp = self._clean_text(raw) - return resp + raw_request = self.convert_request(request) + _log_raw("LLM_RAW_REQUEST", self._name, self._model_name, raw_request) + raw_response = self._client.chat.completions.create(**raw_request) + _log_raw("LLM_RAW_RESPONSE", self._name, self._model_name, raw_response) + return self.convert_response(raw_response) except Exception as e: - logger.exception(f"[AIProvider.chat]: Exception while communicating with LLM: {e}") - return "" - - def _clean_text(self, text: str) -> str: - """Unescape special characters.""" - return text.replace("_quote_", '"').replace("_apostrophe_", "'").replace("", " ") \ - .replace("", " ").replace("", " ").replace("", " ") + error = f"Exception while communicating with LLM: {e}" + logger.exception(f"[AIProvider.chat]: {error}") + return LLMResponse().with_error(error) def stop(self) -> None: self._client.close() diff --git a/providers/mockprovider.py b/providers/mockprovider.py index aa3c91bb..4b77f0bf 100644 --- a/providers/mockprovider.py +++ b/providers/mockprovider.py @@ -1,8 +1,8 @@ import os import lib_llm_ext as llm -import providers +from providers import * -class MockProvider(providers.LLMProvider): +class MockProvider(LLMProvider): def __init__(self): super().__init__() @@ -13,11 +13,11 @@ def start(self) -> None: def stop(self) -> None: self.delegate.stop() - def chat(self, prompt: str, max_tokens: int = 6000, reasoning_mode: str = "medium") -> str: - return self.delegate.chat(prompt, max_tokens, reasoning_mode) + def chat(self, args: LLMRequest) -> LLMResponse: + return self.delegate.chat(args) def loadOmegaPlugin(): - providers.registerLLMProvider("Test", MockProvider()) + registerLLMProvider("Test", MockProvider()) class MockProviderImpl(llm.AbstractAIProvider): """Test provider for mocking LLM output""" @@ -37,8 +37,8 @@ def _llm_mock(self): def is_available(self) -> bool: return self._controller_ip is not None - def chat(self, content: str, max_tokens: int = 6000, reasoning: str = "medium", **kwargs) -> str: - return self._llm_mock().chat(content) + def chat(self, request: LLMRequest) -> LLMResponse: + return self._llm_mock().chat(request) def stop(self) -> None: if self._mock is not None: diff --git a/providers/openai.py b/providers/openai.py index b729cb94..12b0718d 100644 --- a/providers/openai.py +++ b/providers/openai.py @@ -1,13 +1,14 @@ import os import lib_llm_ext as llm -import providers +from providers import * from src.logger import get_logger from config import config_get_by_key -from typing import Dict, Any +from typing import Any +import json logger = get_logger(__name__) -class OpenAIProvider(providers.LLMProvider): +class OpenAIProvider(LLMProvider): def __init__(self): super().__init__() @@ -21,34 +22,75 @@ def start(self) -> None: def stop(self) -> None: self.delegate.stop() - def chat(self, prompt: str, max_tokens: int = 6000, reasoning_mode: str = "medium") -> str: - return self.delegate.chat(prompt, max_tokens, reasoning_mode) + def chat(self, args: LLMRequest) -> LLMResponse: + return self.delegate.chat(args) def loadOmegaPlugin(): - providers.registerLLMProvider("OpenAI", OpenAIProvider()) + registerLLMProvider("OpenAI", OpenAIProvider()) class OpenAIProviderImpl(llm.AIProvider): """OpenAI provider using the Responses API (reasoning models).""" - def prepare_args(self, content: str, max_tokens: int = 6000, - reasoning: str = "medium", **kwargs) -> Dict[str, Any]: - sysmsg, usermsg = llm._split_system_user(content) - args = { - "instructions": sysmsg, + def convert_message(self, message: LLMMessage) -> [dict]: + if isinstance(message, LLMToolCallMessage): + return [self.convert_tool_call(call) for call in message.calls] + elif isinstance(message, LLMToolCallResponseMessage): + return [{ + "type": "function_call_output", + "call_id": message.callid, + "output": message.content + }] + else: + return [{ + "role": message.role, + "content": message.content + }] + + def convert_tool_call(self, call: LLMToolCall) -> dict: + return { + "type": "function_call", + "call_id": call.id, + "name": call.name, + "arguments": json.dumps(call.arguments) + } + + def convert_tool(self, tool: LLMTool) -> dict: + return { + "type": "function", + "name": tool.name, + "description": tool.description, + "parameters": { + "type": "object", + "properties": { param.name: { "type": "string" } for param in tool.parameters }, + "required": [ param.name for param in tool.parameters ] + } + } + + def convert_request(self, request: LLMRequest) -> dict[str, Any]: + default_cache_key = llm._stable_cache_key("openai", + self._model_name, + request.messages[0].content) + input = [] + for msg in request.messages: + input += self.convert_message(msg) + result = { "model": self._model_name, - "input": usermsg, - "max_output_tokens": max_tokens, - "reasoning": {"effort": reasoning}, - "prompt_cache_key": config_get_by_key("OPENAI_PROMPT_CACHE_KEY", llm._stable_cache_key("openai", self._model_name, sysmsg)), + "input": input, + "max_output_tokens": request.max_tokens, + "reasoning": { "effort": request.reasoning_mode }, + "prompt_cache_key": config_get_by_key("OPENAI_PROMPT_CACHE_KEY", + default_cache_key), + "tools": [self.convert_tool(tool) for tool in request.tools], + "tool_choice": "required", } + # GPT-5.5 supports only 24h; GPT-5.4 also supports extended retention. if self._model_name.startswith(("gpt-5.5", "gpt-5.4")): - args["prompt_cache_retention"] = "24h" + result["prompt_cache_retention"] = "24h" - args.update(kwargs) - return args + return result - def extract_raw_response(self, response): + def log_usage_statistics(self, response): usage = getattr(response, "usage", None) if usage: input_tokens = getattr(usage, "input_tokens", None) @@ -63,9 +105,32 @@ def extract_raw_response(self, response): f"total_tokens={total_tokens} cached_tokens={cached_tokens}" ) - return response.output_text or "" - - def chat(self, content: str, max_tokens: int = 6000, reasoning: str = "medium", **kwargs) -> str: + def convert_response(self, raw): + self.log_usage_statistics(raw) + + response = LLMResponse() + output = raw.output + if not output: + return response + + for item in output: + if item.type != "function_call": + continue + tool_call = item + tc = LLMToolCall().with_name(tool_call.name).with_id(tool_call.id) + try: + arguments = json.loads(tool_call.arguments) + except json.JSONDecodeError as error: + response.add_tool_call(tc.with_error(f"Invalid tool arguments from model: {error}")) + else: + if isinstance(arguments, dict): + response.add_tool_call(tc.with_arguments(arguments)) + else: + response.add_tool_call(tc.with_error("Tool arguments must be a JSON object")) + + return response + + def chat(self, request: LLMRequest) -> LLMResponse: """Send chat request, initializing client if needed.""" self._ensure_client() @@ -73,14 +138,13 @@ def chat(self, content: str, max_tokens: int = 6000, reasoning: str = "medium", raise RuntimeError(f"{self.name} not configured (set {self._var_name})") try: - kwargs = self.prepare_args(content, max_tokens, reasoning, **kwargs) - # TODO: This line is the only line which is different to - # super().chat() implementation - response = self._client.responses.create(**kwargs) - raw = self.extract_raw_response(response) - llm._log_raw(self._name, self._model_name, raw) - resp = self._clean_text(raw) - return resp + raw_request = self.convert_request(request) + llm._log_raw("LLM_RAW_REQUEST", self._name, self._model_name, raw_request) + raw_response = self._client.responses.create(**raw_request) + llm._log_raw("LLM_RAW_RESPONSE", self._name, self._model_name, raw_response) + return self.convert_response(raw_response) except Exception as e: - logger.exception(f"[AIProvider.chat]: Exception while communicating with LLM: {e}") - return "" + error = f"Exception while communicating with LLM: {e}" + logger.exception(f"[AIProvider.chat]: {error}") + return LLMResponse().with_error(error) + diff --git a/providers/openaiapi.py b/providers/openaiapi.py index a667fe30..452f6cf2 100644 --- a/providers/openaiapi.py +++ b/providers/openaiapi.py @@ -26,8 +26,8 @@ def start(self) -> None: def stop(self) -> None: self.delegate.stop() - def chat(self, prompt: str, max_tokens: int = 6000, reasoning_mode: str = "medium") -> str: - return self.delegate.chat(prompt, max_tokens, reasoning_mode) + def chat(self, args: providers.LLMRequest) -> providers.LLMResponse: + return self.delegate.chat(args) class OpenAIAPIPreconfigured(OpenAIAPI): diff --git a/providers/openrouter.py b/providers/openrouter.py index a19cc32d..1272271c 100644 --- a/providers/openrouter.py +++ b/providers/openrouter.py @@ -1,6 +1,5 @@ import os -import openai -from typing import Optional, Dict, Any +from typing import Any import lib_llm_ext as llm import providers from src.logger import get_logger @@ -22,8 +21,8 @@ def start(self) -> None: def stop(self) -> None: self.delegate.stop() - def chat(self, prompt: str, max_tokens: int = 6000, reasoning_mode: str = "medium") -> str: - return self.delegate.chat(prompt, max_tokens, reasoning_mode) + def chat(self, args: providers.LLMRequest) -> providers.LLMResponse: + return self.delegate.chat(args) def loadOmegaPlugin(): providers.registerLLMProvider("OpenRouter", OpenRouterProvider()) @@ -31,12 +30,12 @@ def loadOmegaPlugin(): class OpenRouterProviderImpl(llm.AIProvider): """OpenRouter provider with reasoning mode enabled (reasoning tokens excluded from the response).""" - def _openrouter_extra_body(self, content: str, max_tokens: int) -> Dict[str, Any]: - sysmsg, _ = llm._split_system_user(content) + def _openrouter_extra_body(self, request: providers.LLMRequest) -> dict[str, Any]: + sysmsg = request.messages[0].content body = { "reasoning": { "enabled": True, - "max_tokens": max_tokens, + "max_tokens": request.max_tokens, "exclude": True, } } @@ -61,12 +60,10 @@ def _openrouter_extra_body(self, content: str, max_tokens: int) -> Dict[str, Any return body - def prepare_args(self, content: str, max_tokens: int = 6000, - reasoning: str = "medium", **kwargs) -> Dict[str, Any]: - extra_body = llm._merge_dicts( - self._openrouter_extra_body(content, max_tokens), - kwargs.pop("extra_body", None), + def convert_request(self, request: providers.LLMRequest) -> dict[str, Any]: + result = super().convert_request(request) + result['extra_body'] = llm._merge_dicts( + self._openrouter_extra_body(request), + result.pop("extra_body", None), ) - - return super().prepare_args(content, max_tokens, reasoning, - extra_body=extra_body, **kwargs) + return result diff --git a/src/loop.metta b/src/loop.metta index 783b2daa..7c276555 100644 --- a/src/loop.metta +++ b/src/loop.metta @@ -31,14 +31,8 @@ (log INFO "loop" (py-call (rag.init_knowledge "Local")))))) (= (getContext) - (string-safe (py-str ("PROMPT: " (getPrompt (provider)) " SKILLS: " (getSkills) + (string-safe (py-str ("PROMPT: " (getPrompt (provider)) " SKILLS: " (getSkillRules) (newline) (getPromptExtensions) (newline) - " OUTPUT_FORMAT: Up to 5 lines, do not wrap quotes around args, do not use variables:" (newline) - " toolName1 arg1" (newline) - " toolName2 arg2" (newline) - " toolName3 arg3" (newline) - " toolName4 arg4" (newline) - " toolName5 arg5" (newline) " SAVE_PERMANENT_FILES_DIR: " (memoryDirectory) " LAST_SKILL_USE_RESULTS: " (last_chars (get-state &lastresults) (maxFeedback)) " HISTORY: " (getHistory) @@ -64,6 +58,19 @@ (progn (change-state! &error $new) (ALERT_FAILED $a $b)))) ($else $sexpr)))) +(= (collectTools) + (py-call (providers.getTools (getSkills)))) + +(= (messagesGet) + (get-state &messages_all)) + +(= (messagesClear) + (change-state! &messages_all (py-call (utils.listNew)))) + +(= (messagesAppend $item) + (let $episodic (get-state &messages_all) + (change-state! &messages_all (py-call (utils.listAppend $episodic $item))))) + (= (omega) (omega 1)) (= (omega $k) @@ -89,17 +96,20 @@ ($_ (if (and (> $k 1) $msgnew) (change-state! &loops (maxNewInputLoops)) _))) (if (> (get-state &loops) 0) - (let* (($lastmessage (if $msgnew (HUMAN-MSG: $msg) (if (spamShield) " DO NOT RE-SEND OR SPAM!" ""))) + (let* (($lastmessage (if $msgnew $msg (if (spamShield) " DO NOT RE-SEND OR SPAM!" ""))) ($_ (change-state! &nextWakeAt (+ (get_time) (wakeupInterval)))) ($_ (log INFO "loop" $lastmessage)) - ($send (py-str ($prompt :-:-:-: $lastmessage))) - ($_ (log INFO "loop" (CHARS_SENT: (string_length $send) $send))) - ($respi (llmProviderChat $send (maxOutputToken) (reasoningMode))) - ($resp (py-call (helper.balance_parentheses $respi))) - ($response (if (== "(" (first_char $resp)) $resp (progn (log INFO "loop" $resp) (repr (REMEMBER:OUTPUT_NOTHING_ELSE_THAN: ((skill arg) ...)))))) - ($sexpr (catch (sread $response))) + ($system (llmRequestMessage role "system" content $prompt)) + ($_ (messagesClear)) + ($_ (messagesAppend (llmRequestMessage role "user" content $lastmessage))) + ($request (llmRequest $system (messagesGet) (maxOutputToken) (reasoningMode) (collectTools))) + ($_ (log INFO "loop" (REQUEST: (py-str ($request))))) + ($response (llmProviderChat $request)) + ($_ (log INFO "loop" (RESPONSE: (py-str ($response))))) + ($resp (py-call (providers.llmResponseToSExpr $response))) + ($sexpr (catch (sread $resp))) ($_ (change-state! &error ())) - ($mcerr (HandleError MULTI_COMMAND_FAILURE_NOTHING_WAS_DONE_PLEASE_CORRECT_PARENTHESES_AND_USE_QUOTES_AND_RETRY $response $sexpr)) + ($mcerr (HandleError MULTI_COMMAND_FAILURE_NOTHING_WAS_DONE_PLEASE_CORRECT_PARENTHESES_AND_USE_QUOTES_AND_RETRY $resp $sexpr)) ($_ (log INFO "loop" (RESPONSE: $sexpr))) ($results (if (== $mcerr $sexpr) (RESULTS: (collapse (let $s (superpose $sexpr) @@ -109,7 +119,7 @@ $precheck))))))) (RESULTS: $mcerr))) ($_ (log INFO "loop" (RESPONSE: $results)))) - (progn (if (or $msgnew (not (== $sexpr ()))) (addToHistory $msg $response $sexpr $msgnew) _) + (progn (if (or $msgnew (not (== $sexpr ()))) (addToHistory $msg $resp $sexpr $msgnew) _) (change-state! &lastresults (string-safe (repr $results))))) (if (> (get_time) (get-state &nextWakeAt)) (change-state! &loops (+ 1 (maxWakeLoops))) _))) diff --git a/src/plugin.metta b/src/plugin.metta index 0f3f8a36..dd171aa5 100644 --- a/src/plugin.metta +++ b/src/plugin.metta @@ -12,8 +12,7 @@ (py-call (plugin.loadPythonPlugin $name $location))) (= (loadPlugin (metta $name $location)) - (let True (atom-string $location $locationstr) - (loadMettaPlugin $name $locationstr))) + (loadMettaPlugin $name $location)) (= (loadMettaPlugin $name $location) (let $rc1 diff --git a/src/providers.metta b/src/providers.metta index c3b397b6..82a3a730 100644 --- a/src/providers.metta +++ b/src/providers.metta @@ -1,6 +1,41 @@ (= (llmProviderStart $provider) (py-call (providers.llmProviderStart $provider))) -(= (llmProviderChat $prompt $max_tokens $reasoning_mode) - (py-call (providers.llmProviderChat $prompt $max_tokens $reasoning_mode))) +(= (llmProviderChat $request) + (py-call (providers.llmProviderChat $request))) +(= (llmRequestMessage role $role content $content) + (py-call (providers.llmRequestMessage $role $content))) + +(= (llmRequestMessage role $role callid $callid content $content) + (py-call (providers.llmToolCallResponseMessage $role $callid $content))) + +(= (llmRequestMessage role $role calls $calls content $content) + (py-call (providers.llmToolCallMessage $role $calls $content))) + +(= (llmRequest $prompt $episodes $max_tokens $reasoning_mode $tools) + (py-call (providers.llmRequest $prompt $episodes $max_tokens $reasoning_mode $tools))) + +(= (llmResponseIsEmpty $response) + (py-call (providers.llmResponseIsEmpty $response))) + +(= (llmResponseCalls $response) + (py-call (providers.llmResponseCalls $response))) + +(= (llmToolCallGetName $call) + (py-call (providers.llmToolCallGetName $call))) + +(= (llmToolCallGetId $call) + (py-call (providers.llmToolCallGetId $call))) + +(= (llmToolCallGetArguments $call) + (py-str ((py-call (providers.llmToolCallGetArguments $call))))) + +(= (llmToolCallIsError $call) + (py-call (providers.llmToolCallIsError $call))) + +(= (llmToolCallGetError $call) + (py-call (providers.llmToolCallGetError $call))) + +(= (llmResponseToSExpr $call) + (py-call (providers.llmResponseToSExpr $call))) diff --git a/src/providers.py b/src/providers.py index 8418f67e..4d6f1176 100644 --- a/src/providers.py +++ b/src/providers.py @@ -1,9 +1,195 @@ import logging +from typing import List, Self logger = logging.getLogger(__name__) _llmProviderRegistry = {} +class LLMTool: + + def __init__(self): + self.name = None + self.description = None + self.parameters = [] + + def with_name(self, name): + self.name = name + return self + + def with_description(self, description): + self.description = description + return self + + def add_parameter(self, parameter): + self.parameters.append(parameter) + return self + + def with_parameters(self, parameters): + self.parameters = parameters + return self + + def __repr__(self): + return f"LLMTool[name={self.name!r}, description={self.description!r}, parameters={self.parameters!r}]" + +class LLMToolParameter: + + def with_name(self, name): + self.name = name + return self + + def __repr__(self): + return f"LLMToolParameter[name={self.name!r}]" + +class LLMToolCall: + + def __init__(self): + self.name = None + self.id = None + self.error = None + self.arguments = {} + self.tool = None + + def with_name(self, name): + self.name = name + return self + + def with_id(self, id): + self.id = id + return self + + def is_error(self): + return bool(self.error) + + def with_error(self, error): + self.error = error + return self + + def set_error(self, error): + self.error = error + + def add_argument(self, name, value): + self.arguments[name] = value + return self + + def with_arguments(self, arguments): + self.arguments = arguments + return self + + def set_tool(self, tool: LLMTool): + self.tool = tool + + def __repr__(self): + return f"LLMToolCall[id={self.id!r},name={self.name!r},arguments={self.arguments!r},error={self.error!r}]" + + def __eq__(self, other): + return (self.name == other.name + and self.id == other.id + and self.error == other.error + and self.arguments == other.arguments + and self.tool == other.tool) + +class LLMMessage: + + def __init__(self): + self.role = None + self.content = None + + def with_role(self, role): + self.role = role + return self + + def with_content(self, content): + self.content = content + return self + + def __repr__(self): + return f"LLMMessage[role={self.role!r},content={self.content!r}]" + +class LLMToolCallResponseMessage(LLMMessage): + + def __init__(self): + super().__init__() + self.callid = None + + def with_callid(self, callid): + self.callid = callid + return self + + def __repr__(self): + return f"LLMToolCallResponseMessage[role={self.role!r},content={self.content!r},callid={self.callid!r}]" + +class LLMToolCallMessage(LLMMessage): + + def __init__(self): + super().__init__() + self.calls: [LLMToolCall] = [] + + def with_calls(self, calls): + self.calls = calls + return self + + def __repr__(self): + return f"LLMToolCallMessage[role={self.role!r},content={self.content!r},calls={self.calls!r}]" + +class LLMRequest: + + def __init__(self): + self.messages: [LLMMessage] = [] + self.max_tokens = 6000 + self.reasoning_mode = "medium" + self.tools = [] + self.tool_by_name = {} + + def add_message(self, message): + self.messages.append(message) + return self + + def with_messages(self, messages): + self.messages = messages + return self + + def with_max_tokens(self, max_tokens): + self.max_tokens = max_tokens + return self + + def with_reasoning_mode(self, reasoning_mode): + self.reasoning_mode = reasoning_mode + return self + + def with_tools(self, tools: List[LLMTool]): + self.tools = tools + self.tool_by_name = { t.name: t for t in tools } + return self + + def has_tool(self, name): + return name in self.tool_by_name + + def get_tool(self, name): + return self.tool_by_name.get(name, None) + + def __repr__(self): + return f"LLMRequest[messages={self.messages!r}, max_tokens={self.max_tokens!r}, reasoning_mode={self.reasoning_mode!r}, tools={self.tools!r}]" + +class LLMResponse: + + def __init__(self): + self.calls: List[LLMToolCall] = [] + self.error = None + + def add_tool_call(self, call: LLMToolCall) -> Self: + self.calls.append(call) + return self + + def with_error(self, error: str) -> Self: + self.error = error + return self + + def __repr__(self): + return f"LLMResponse[calls={self.calls!r},error={self.error!r}]" + + def __eq__(self, other): + return self.calls == other.calls and self.error == other.error + class LLMProvider: """LLM provider implementation""" @@ -15,7 +201,7 @@ def stop(self) -> None: """Stop and LLM provider and free resources""" pass - def chat(self, prompt: str, max_tokens: int = 6000, reasoning_mode: str = "medium") -> str: + def chat(self, request: LLMRequest) -> LLMResponse: """Chat with LLM provider""" raise NotImplementedError() @@ -43,7 +229,96 @@ def llmProviderStart(provider): raise RuntimeError(error) _llmprovider.start() -def llmProviderChat(prompt, max_tokens, reasoning_mode): +def llmProviderChat(request): """Chat via selected LLM provider""" global _llmprovider - return _llmprovider.chat(prompt, max_tokens, reasoning_mode) + try: + response = _llmprovider.chat(request) + return _validate_response(request, response) + except Exception: + logger.exception("Exception while getting LLM response") + return LLMResponse() + +def _validate_response(request: LLMRequest, response: LLMResponse) -> LLMResponse: + for call in response.calls: + if call.is_error(): + continue + if not request.has_tool(call.name): + call.set_error(f"Unknown tool: {call.name!r}") + call.set_tool(request.get_tool(call.name)) + for parameter in call.tool.parameters: + if not parameter.name in call.arguments: + call.set_error(f"Call tool parameter is not set: tool: {call.name!r}, parameter: {parameter.name!r}") + break + return response + +def getTools(skills): + """Form a list of tools for LLM from the list of skills""" + tools = [] + for name, desc, params in skills: + tool = LLMTool().with_name(name).with_description(desc) + for param in params: + tool.add_parameter(LLMToolParameter().with_name(param)) + tools.append(tool) + return tools + +def llmRequestMessage(role, content): + return LLMMessage().with_role(role).with_content(content) + +def llmToolCallResponseMessage(role, callid, content): + return (LLMToolCallResponseMessage().with_role(role) + .with_callid(callid) + .with_content(content)) + +def llmToolCallMessage(role, calls: [LLMToolCall], content): + return (LLMToolCallMessage().with_role(role) + .with_calls(calls) + .with_content(content)) + +def llmRequest(prompt, episodes, max_tokens, reasoning_mode, tools): + return (LLMRequest().with_messages([prompt] + episodes) + .with_max_tokens(max_tokens) + .with_reasoning_mode(reasoning_mode) + .with_tools(tools)) + +def llmResponseIsEmpty(response: LLMResponse): + return len(response.calls) == 0 + +def llmResponseCalls(response: LLMResponse): + return response.calls + +def llmResponseIsError(response: LLMResponse): + return bool(response.error) + +def llmResponseGetError(response: LLMResponse): + return response.error + +def llmToolCallGetName(call: LLMToolCall): + return call.name + +def llmToolCallGetId(call: LLMToolCall): + return call.id + +def llmToolCallGetArguments(call: LLMToolCall): + return call.arguments + +def llmToolCallIsError(call: LLMToolCall): + return bool(call.error) + +def llmToolCallGetError(call: LLMToolCall): + return call.error + +def llmToolCallToSExpr(call: LLMToolCall): + sexpr = f"({call.name} " + for parameter in call.tool.parameters: + if parameter.name in call.arguments: + arg = call.arguments[parameter.name] + arg = arg.replace('"','\\"') + sexpr = sexpr + f"\"{arg}\" " + return sexpr[:-1] + ")" + +def llmResponseToSExpr(response: LLMResponse) -> str: + sexpr = "(" + for call in response.calls: + sexpr = sexpr + llmToolCallToSExpr(call) + return sexpr + ")" diff --git a/src/skills.metta b/src/skills.metta index 70f89648..e8778d4a 100644 --- a/src/skills.metta +++ b/src/skills.metta @@ -1,10 +1,7 @@ (= (getSkills) - (collapse (dynamic-skill $_))) + (collapse (match &self (dynamic-skill $name $info) $info))) -; dynamic skill placeholder to eliminate error when no dynamic skill is added -(= (dynamic-skill placeholder) (empty)) - -(= (getStaticSkills) +(= (getSkillRules) (;ADDITIONAL RULES AND CLARIFICATIONS FOR SKILLS "Example to invoke Non-Axiomatic Logic via MeTTa: " "metta (|- ((--> (× sam garfield) friend) (stv 1.0 0.9))" @@ -28,11 +25,11 @@ ; function has no arguments then empty tuple () should be passed. (: add-skill (-> Atom String Expression Bool)) (= (add-skill $function $description $arguments) - (let $text (strings-concat ("- " $description ": " $function " " (join " " $arguments))) + (let $text (strings-concat ($description ": " $function " " (join " " $arguments))) (progn (py-call (helper.add_llm_command $function)) (log INFO "skills" (strings-concat ("Add skill: " $text))) - (add-atom &self (= (dynamic-skill $function) $text))))) + (add-atom &self (dynamic-skill $function ($function $description $arguments))) ))) ; Remove skill from the list of skills available to the agent. ; $function - MeTTa function which implements the skill @@ -40,7 +37,7 @@ (progn (py-call (helper.remove_llm_command $function)) (log INFO "skills" (strings-concat ("Remove skill: " $function))) - (collapse (match &self (= (dynamic-skill $function) $text) (remove-atom &self (= (dynamic-skill $function) $text)))) + (collapse (match &self (dynamic-skill $function $info) (remove-atom &self (dynamic-skill $function $info)))) True)) ; Add new section to the LLM's prompt. The extension is inserted into the diff --git a/src/utils.metta b/src/utils.metta index 99de9f64..3aa2e0f6 100644 --- a/src/utils.metta +++ b/src/utils.metta @@ -98,13 +98,10 @@ (= (atom-concat $a $b) (atomic_list_concat ($a $b))) -(= (atom-string $atom $str) - (empty-to-bool (translatePredicate (atom_string $atom $str)))) - (= (projectRootDirectory) (py-call (helper.projectRootDirectory))) (= (joinPath $parts) (progn (let $path (py-call (helper.joinPath $parts)) ()) - (let True (atom-string $path $pathstr) $pathstr))) + (swrite $path))) diff --git a/src/utils.py b/src/utils.py new file mode 100644 index 00000000..e6a74c8e --- /dev/null +++ b/src/utils.py @@ -0,0 +1,9 @@ +def listNew(): + return [] + +def listAppend(list, item): + list.append(item) + return list + +def listSlice(list, start, stop=None): + return list[start:stop] diff --git a/tests/src_skills.metta b/tests/src_skills.metta index 18d764df..961dc14c 100644 --- a/tests/src_skills.metta +++ b/tests/src_skills.metta @@ -12,12 +12,12 @@ !(test (progn (add-skill test-skill "This is a test skill" (test_arg)) - (expression-count-item (getSkills) "- This is a test skill: test-skill test_arg")) + (expression-count-item (getSkills) (test-skill "This is a test skill" (test_arg)))) 1) !(test (progn (remove-skill test-skill) - (expression-count-item (getSkills) "- This is a test skill: test-skill test_arg")) + (expression-count-item (getSkills) (test-skill "This is a test skill" (test_arg)))) 0) !(test (progn diff --git a/tests/src_utils.metta b/tests/src_utils.metta index 8c0dc266..255cfc62 100644 --- a/tests/src_utils.metta +++ b/tests/src_utils.metta @@ -19,3 +19,7 @@ "a/b") !(test (joinPath ("a/" "b")) "a/b") + +!(test (swrite a) "a") +!(test (swrite "a") "\"a\"") +!(test (swrite (a b)) "(a b)") From ac44ea659aa07ecdf17aae074f2b17fde1475184 Mon Sep 17 00:00:00 2001 From: Vitaly Bogdanov Date: Mon, 21 Sep 2026 17:55:20 +0300 Subject: [PATCH 5/9] Remove spamShield flag --- config/config.yaml | 2 -- src/loop.metta | 4 +--- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/config/config.yaml b/config/config.yaml index 9d74a649..478b36d1 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -4,8 +4,6 @@ maxNewInputLoops: 50 # Extra turns granted on each scheduled wake-up maxWakeLoops: 1 -# (deprecated) -spamShield: False # Delay between loop iterations (seconds) sleepInterval: 1 # LLM model to load overrides *_model parameters see below diff --git a/src/loop.metta b/src/loop.metta index 7c276555..7ff84296 100644 --- a/src/loop.metta +++ b/src/loop.metta @@ -6,12 +6,10 @@ (= (reasoningMode) (empty)) (= (wakeupInterval) (empty)) (= (memoryDirectory) (empty)) -(= (spamShield) (empty)) ; TODO: this parameter is considered deprecated (= (initLoop) (progn (configure maxNewInputLoops 50) ;20 (configure maxWakeLoops 1) - (configure spamShield False) (configure sleepInterval 1) ;10 (configure provider Anthropic) (configure maxOutputToken 6000) @@ -96,7 +94,7 @@ ($_ (if (and (> $k 1) $msgnew) (change-state! &loops (maxNewInputLoops)) _))) (if (> (get-state &loops) 0) - (let* (($lastmessage (if $msgnew $msg (if (spamShield) " DO NOT RE-SEND OR SPAM!" ""))) + (let* (($lastmessage (if $msgnew $msg "")) ($_ (change-state! &nextWakeAt (+ (get_time) (wakeupInterval)))) ($_ (log INFO "loop" $lastmessage)) ($system (llmRequestMessage role "system" content $prompt)) From 7afa1d5467b9051779a88da2d1f252f84da079a6 Mon Sep 17 00:00:00 2001 From: Vitaly Bogdanov Date: Mon, 21 Sep 2026 18:05:57 +0300 Subject: [PATCH 6/9] Remove balance_parentheses() function and its dependencies --- Autotests/unit/test_helper_parsing.py | 97 ----------- docs/reference-failure-modes.md | 1 - docs/reference-internals-loop.md | 1 - docs/reference-internals-skill-dispatch.md | 1 - docs/reference-python-bridges.md | 1 - src/helper.py | 178 --------------------- src/skills.metta | 2 - 7 files changed, 281 deletions(-) diff --git a/Autotests/unit/test_helper_parsing.py b/Autotests/unit/test_helper_parsing.py index dcdb4afc..01a59939 100644 --- a/Autotests/unit/test_helper_parsing.py +++ b/Autotests/unit/test_helper_parsing.py @@ -1,12 +1,4 @@ """In-process unit tests for the parsing helpers in src/helper.py. - -quote_arg / split_command_blocks / balance_parentheses turn the model's -loosely-structured reply into the s-expression the agent actually runs, so a -regression here silently corrupts every skill call. The module ships one inline -test_balance_parenthesis(), but it never exercises backslashes, embedded quotes -or quote_arg directly — the exact paths behind the escaping (#262) and command -parsing (#209) bugs. These cover them. - No container, no network, no token — same pattern as test_fileio_verified_writes.py: the module is loaded by file path. """ @@ -37,95 +29,6 @@ def helper(): return _load_helper() -# --- quote_arg ------------------------------------------------------------ - -def test_quote_arg_wraps_plain_token(helper): - assert helper.quote_arg("hello") == '"hello"' - - -def test_quote_arg_passes_through_already_quoted_single_line(helper): - assert helper.quote_arg('"hello"') == '"hello"' - - -def test_quote_arg_requotes_when_quoted_value_contains_newline(helper): - # the pass-through guard excludes newlines, so this goes through json.dumps - assert helper.quote_arg('"a\nb"') == '"\\"a\\nb\\""' - - -def test_quote_arg_escapes_backslash(helper): - assert helper.quote_arg("a\\b") == '"a\\\\b"' - - -def test_quote_arg_escapes_trailing_backslash_windows_path(helper): - # #262: a trailing backslash must not escape the closing quote - assert helper.quote_arg("C:\\path\\to\\") == '"C:\\\\path\\\\to\\\\"' - - -def test_quote_arg_escapes_embedded_double_quote(helper): - assert helper.quote_arg('say "hi"') == '"say \\"hi\\""' - - -def test_quote_arg_escapes_real_newline(helper): - assert helper.quote_arg("a\nb") == '"a\\nb"' - - -def test_quote_arg_keeps_non_ascii_unescaped(helper): - # ensure_ascii=False keeps UTF-8 readable instead of \uXXXX - assert helper.quote_arg("café €") == '"café €"' - - -# --- starts_command_line -------------------------------------------------- - -def test_starts_command_line_recognizes_known_commands(helper): - assert helper.starts_command_line("send hi") is True - assert helper.starts_command_line("(send hi)") is True - assert helper.starts_command_line(" shell ls") is True - assert helper.starts_command_line("write-file a b") is True - - -def test_starts_command_line_rejects_prose_and_blanks(helper): - assert helper.starts_command_line("hello there") is False - assert helper.starts_command_line("") is False - assert helper.starts_command_line("(") is False - - -# --- split_command_blocks ------------------------------------------------- - -def test_split_attaches_continuation_lines_to_the_current_command(helper): - assert helper.split_command_blocks("send hello\nmore text\npin done") == [ - "send hello\nmore text", - "pin done", - ] - - -def test_split_single_command_is_one_block(helper): - assert helper.split_command_blocks("shell ls -la") == ["shell ls -la"] - - -def test_split_drops_blank_lines_between_commands(helper): - assert helper.split_command_blocks("send a\n\n\npin b") == ["send a", "pin b"] - - -# --- balance_parentheses (escaping paths the inline test misses) ---------- - -def test_balance_escapes_backslashes_in_send_content(helper): - assert helper.balance_parentheses("send C:\\path\\to") == '((send "C:\\\\path\\\\to"))' - - -def test_balance_escapes_backslashes_in_write_file_content(helper): - assert helper.balance_parentheses("write-file a.txt C:\\x\\y") == ( - '((write-file "a.txt" "C:\\\\x\\\\y"))' - ) - - -def test_balance_escapes_embedded_quotes_in_content(helper): - assert helper.balance_parentheses('send say "hi" ok') == '((send "say \\"hi\\" ok"))' - - -def test_balance_escapes_backslash_in_shell_command(helper): - assert helper.balance_parentheses("shell echo a\\b") == '((shell "echo a\\\\b"))' - - # --- normalize_string ----------------------------------------------------- def test_normalize_string_decodes_bytes(helper): diff --git a/docs/reference-failure-modes.md b/docs/reference-failure-modes.md index 4ef92182..119faa6b 100644 --- a/docs/reference-failure-modes.md +++ b/docs/reference-failure-modes.md @@ -89,7 +89,6 @@ Measured across 4,500+ operational cycles, the top error categories are: 1. **Commands not executed** (`NOTHING_WAS_DONE`) — the LLM produced output that was not a valid skill tuple. 2. **Multi-command parsing failures.** -3. **Parenthesis mismatches** — repaired best-effort by `helper.balance_parentheses`, but not always successfully. These are **the most frequent failure mode in the entire system**, not occasional glitches. diff --git a/docs/reference-internals-loop.md b/docs/reference-internals-loop.md index 7d9709ac..5ace0932 100644 --- a/docs/reference-internals-loop.md +++ b/docs/reference-internals-loop.md @@ -36,7 +36,6 @@ Also creates shared state slots: - `Anthropic` → `lib_llm_ext.useClaude` - `ASICloud` → `lib_llm_ext.useMiniMax` - else → `lib_llm_ext.useAsi1` -7. **Repair parentheses** — `helper.balance_parentheses` fixes common mismatches before parsing. 8. **Parse** — `sread` on the repaired string; if it does not start with `(`, the loop feeds back a reminder prompt. 9. **Dispatch skills** — `(superpose $sexpr)` runs each skill, capturing errors via `HandleError`. 10. **Record** — `addToHistory` appends human message + response + any errors to `memory/history.metta`, provided something new happened. diff --git a/docs/reference-internals-skill-dispatch.md b/docs/reference-internals-skill-dispatch.md index 9d26dc62..2cb37b1a 100644 --- a/docs/reference-internals-skill-dispatch.md +++ b/docs/reference-internals-skill-dispatch.md @@ -17,7 +17,6 @@ With the hard rules that every argument is a quoted string and no MeTTa variable From `src/loop.metta`: 1. **Raw LLM string** → `$respi`. -2. **Parenthesis repair** — `helper.balance_parentheses $respi` → `$resp`. Common LLM mistakes (missing closers) are fixed here. 3. **First-character check** — if `$resp` does not start with `(`, the agent receives a reminder prompt instead of a real dispatch; the LLM tries again next turn. 4. **Parse** — `catch (sread $response)` → `$sexpr`. On parse failure, `HandleError` records `MULTI_COMMAND_FAILURE_...`. 5. **Fan out** — `(superpose $sexpr)` produces one binding per skill call in the tuple. diff --git a/docs/reference-python-bridges.md b/docs/reference-python-bridges.md index 8346a449..ed886c9c 100644 --- a/docs/reference-python-bridges.md +++ b/docs/reference-python-bridges.md @@ -79,7 +79,6 @@ String and time utilities used by the loop. | Function | Purpose | |---|---| -| `balance_parentheses(str)` | Attempt to repair mismatched parentheses in LLM output before `sread` parses it. | | `normalize_string(obj)` | Render a skill return value into a string safe to embed in the next prompt. | | `around_time(ts, n)` | Backs `(episodes ts)` — returns `n` lines of `memory/history.metta` around `ts`. | diff --git a/src/helper.py b/src/helper.py index a28ad313..9624cca4 100644 --- a/src/helper.py +++ b/src/helper.py @@ -15,42 +15,6 @@ logger = get_logger(__name__) TS_RE = re.compile(r'^\("(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"') -STATIC_LLM_COMMANDS = { - "append-file", - "episodes", - "metta", - "pin", - "query", - "read-file", - "remember", - "search", - "send", - "shell", - "version", - "websearch", - "write-file", - "get-io-policy", - "write-file-b64", - "delete-file", -} -LLM_COMMANDS = set(STATIC_LLM_COMMANDS) -TWO_ARG_COMMANDS = { - "write-file", - "append-file", - "write-file-b64" -} - - -def add_llm_command(command): - LLM_COMMANDS.add(str(command)) - return True - - -def remove_llm_command(command): - command = str(command) - if command not in STATIC_LLM_COMMANDS: - LLM_COMMANDS.discard(command) - return True def extract_timestamp(line): m = TS_RE.search(line) @@ -92,105 +56,6 @@ def around_time(needle_time_str, k): ret += f"{lineno}:{line}" return ret -def quote_arg(x): - if x.startswith('"') and x.endswith('"') and "\n" not in x: - return x - else: - return json.dumps(x, ensure_ascii=False) - -def starts_command_line(line): - s = line.lstrip() - if not s: - return False - # allow "(send ...)" as command start too - if s.startswith("("): - s = s[1:].lstrip() - if not s: - return False - first = s.split(maxsplit=1)[0].rstrip(")") - return first in LLM_COMMANDS - -def split_command_blocks(s): - blocks = [] - cur = [] - for raw in s.splitlines(): - if not raw.strip(): - if cur: - cur.append(raw) - continue - if starts_command_line(raw) and cur: - blocks.append("\n".join(cur).strip()) - cur = [raw] - else: - cur.append(raw) - if cur: - blocks.append("\n".join(cur).strip()) - return blocks - -def balance_parentheses(s): - s = s.replace("_quote_", '"').replace("_newline_", "\n") - sexprs = [] - for line in split_command_blocks(s): - line = line.strip() - if not line: - continue - if line.startswith("(-"): - line = "(pin " + line[2:] - elif line.startswith("-"): - line = "pin " + line[1:] - # remove one outer (...) if present - if line.startswith("(") and line.endswith(")"): - line = line[1:-1].strip() - elif line.startswith("("): - line = line[1:].strip() - parts = line.split(maxsplit=1) - if not parts: - continue - cmd = parts[0] - rest = parts[1].strip() if len(parts) > 1 else "" - if cmd not in LLM_COMMANDS: - # Do not let model commentary become a MeTTa expression. The - # loop turns this into ALERT_FAILED feedback for the next turn. - sexprs.append(f"(Error UNKNOWN_SKILL_CALL {quote_arg(line)})") - continue - if cmd in TWO_ARG_COMMANDS: - if not rest: - sexprs.append(f"({cmd})") - continue - # filename is first token unless already quoted - if rest.startswith('"'): - end = 1 - escaped = False - while end < len(rest): - ch = rest[end] - if ch == '"' and not escaped: - break - escaped = (ch == '\\' and not escaped) - if ch != '\\': - escaped = False - end += 1 - if end < len(rest) and rest[end] == '"': - filename = rest[:end+1] - content = rest[end+1:].strip() - else: - filename = quote_arg(rest[1:]) - content = "" - else: - split_rest = rest.split(maxsplit=1) - filename = quote_arg(split_rest[0]) - content = split_rest[1].strip() if len(split_rest) > 1 else "" - if content: - sexprs.append(f"({cmd} {filename} {quote_arg(content)})") - else: - sexprs.append(f"({cmd} {filename})") - continue - if rest: - sexprs.append(f"({cmd} {quote_arg(rest)})") - else: - sexprs.append(f"({cmd})") - ret = " ".join(sexprs) - return "(" + ret + ")" - def normalize_string(x): try: if isinstance(x, bytes): @@ -265,48 +130,5 @@ def test_omega_version(): (root / "version").write_text("Omega v1.2.3\n", encoding="utf-8") assert omega_version(root) == "Omega version=v1.2.3" - -def test_balance_parenthesis(): - assert balance_parentheses('(write-file test.txt hello world)') == '((write-file "test.txt" "hello world"))' - assert balance_parentheses('(append-file test.txt hello world)') == '((append-file "test.txt" "hello world"))' - assert balance_parentheses('(write-file-b64 test.txt aGVsbG8=)') == '((write-file-b64 "test.txt" "aGVsbG8="))' - assert balance_parentheses('write-file-b64 test.txt aGVsbG8=') == '((write-file-b64 "test.txt" "aGVsbG8="))' - assert balance_parentheses('(write-file "test.txt" hello world)') == '((write-file "test.txt" "hello world"))' - assert balance_parentheses('(write-file "test.txt" "hello world")') == '((write-file "test.txt" "hello world"))' - assert balance_parentheses('(write-file test.txt "hello world")') == '((write-file "test.txt" "hello world"))' - assert balance_parentheses('(send test.xt hello world)') == '((send "test.xt hello world"))' - assert balance_parentheses('write-file test.txt hello world') == '((write-file "test.txt" "hello world"))' - assert balance_parentheses('append-file test.txt hello world') == '((append-file "test.txt" "hello world"))' - assert balance_parentheses('write-file "test.txt" hello world') == '((write-file "test.txt" "hello world"))' - assert balance_parentheses('write-file "test.txt" "hello world"') == '((write-file "test.txt" "hello world"))' - assert balance_parentheses('write-file test.txt "hello world"') == '((write-file "test.txt" "hello world"))' - assert balance_parentheses('send test.xt hello world') == '((send "test.xt hello world"))' - assert balance_parentheses('send Here are the planets:\n1. Mercury\n2. Venus') == '((send "Here are the planets:\\n1. Mercury\\n2. Venus"))' - assert balance_parentheses('send Here are the options:\n- MacBook Air\n- ThinkPad X1\npin done') == '((send "Here are the options:\\n- MacBook Air\\n- ThinkPad X1") (pin "done"))' - assert balance_parentheses('(shell "pwd")\n(version)') == '((shell "pwd") (version))' - assert balance_parentheses('send "Plain text version:"\n**Mars** - red planet\nNote: Pluto is a dwarf planet') == '((send "\\\"Plain text version:\\\"\\n**Mars** - red planet\\nNote: Pluto is a dwarf planet"))' - assert balance_parentheses('(send Here are the planets:\n1. Mercury\n2. Venus)') == '((send "Here are the planets:\\n1. Mercury\\n2. Venus"))' - assert balance_parentheses('send "hello" world') == '((send "\\"hello\\" world"))' - assert balance_parentheses('send "Hello"\nHow are you?') == '((send "\\"Hello\\"\\nHow are you?"))' - # bare "()" lines yield no tokens after _strip_outer_parens and must be skipped, not crash - assert balance_parentheses('()') == '()' - assert balance_parentheses('') == '()' - assert balance_parentheses(' ') == '()' - assert balance_parentheses('()\nsend hello') == '((send "hello"))' - assert balance_parentheses('write-file "test.txt" hello\nworld') == '((write-file "test.txt" "hello\\nworld"))' - assert balance_parentheses('- Found a bug') == '((pin "Found a bug"))' - assert balance_parentheses('(- Found a bug)') == '((pin "Found a bug"))' - assert balance_parentheses('- Found\na\nbug') == '((pin "Found\\na\\nbug"))' - assert balance_parentheses('(- Found a bug') == '((pin "Found a bug"))' - assert balance_parentheses('(No "action needed")') == \ - '((Error UNKNOWN_SKILL_CALL "No \\"action needed\\""))' - add_llm_command("workflow-load-instructions") - assert balance_parentheses('workflow-load-instructions test-workflow') == \ - '((workflow-load-instructions "test-workflow"))' - remove_llm_command("workflow-load-instructions") - assert balance_parentheses('workflow-load-instructions test-workflow') == \ - '((Error UNKNOWN_SKILL_CALL "workflow-load-instructions test-workflow"))' - if __name__ == "__main__": test_omega_version() - test_balance_parenthesis() diff --git a/src/skills.metta b/src/skills.metta index e8778d4a..16df25a4 100644 --- a/src/skills.metta +++ b/src/skills.metta @@ -27,7 +27,6 @@ (= (add-skill $function $description $arguments) (let $text (strings-concat ($description ": " $function " " (join " " $arguments))) (progn - (py-call (helper.add_llm_command $function)) (log INFO "skills" (strings-concat ("Add skill: " $text))) (add-atom &self (dynamic-skill $function ($function $description $arguments))) ))) @@ -35,7 +34,6 @@ ; $function - MeTTa function which implements the skill (= (remove-skill $function) (progn - (py-call (helper.remove_llm_command $function)) (log INFO "skills" (strings-concat ("Remove skill: " $function))) (collapse (match &self (dynamic-skill $function $info) (remove-atom &self (dynamic-skill $function $info)))) True)) From c795992535c0bfcdfb9c1f12a12fc280089a95be Mon Sep 17 00:00:00 2001 From: Vitaly Bogdanov Date: Tue, 22 Sep 2026 18:43:46 +0300 Subject: [PATCH 7/9] Fix logging in OpenAI provider implementation --- providers/openai.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/providers/openai.py b/providers/openai.py index 003c0b07..1371e357 100644 --- a/providers/openai.py +++ b/providers/openai.py @@ -106,7 +106,7 @@ def log_usage_statistics(self, response): ) def convert_response(self, raw): - llm._log_responses_completion(raw) + llm._log_responses_completion(self._name, self._model_name, raw) response = LLMResponse() output = raw.output From 1ffb1016b16533e2b77e8fb273fab4ffa87f2130 Mon Sep 17 00:00:00 2001 From: Vitaly Bogdanov Date: Tue, 22 Sep 2026 18:44:50 +0300 Subject: [PATCH 8/9] Don't send empty input where no user message is sent --- src/loop.metta | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/loop.metta b/src/loop.metta index 47010e76..bf1989df 100644 --- a/src/loop.metta +++ b/src/loop.metta @@ -20,6 +20,7 @@ (change-state! &prevmsg "") (change-state! &lastresults "") (change-state! &loops (maxNewInputLoops)) + (messagesClear) )) (= (initKnowledge) @@ -93,7 +94,7 @@ ($_ (if (and (> $k 1) $msgnew) (change-state! &loops (maxNewInputLoops)) _))) (if (> (get-state &loops) 0) - (let* (($lastmessage (if $msgnew $msg "")) + (let* (($lastmessage (if $msgnew $msg "[NO NEW USER INPUT. CONTINUE AUTONOMOUS WORK. DO NOT REPEAT THE PREVIOUS RESPONSE. ONLY USE send FOR GENUINELY NEW INFORMATION OR WHEN USER INPUT IS NEEDED.]")) ($_ (change-state! &nextWakeAt (+ (get_time) (wakeupInterval)))) ($_ (log INFO "loop" $lastmessage)) ($system (llmRequestMessage role "system" content $prompt)) From 125889524b707e1ee55d12df3fa7bcf9f67c2a67 Mon Sep 17 00:00:00 2001 From: Vitaly Bogdanov Date: Wed, 23 Sep 2026 12:02:19 +0300 Subject: [PATCH 9/9] Return results of the previous calls via OpenAI API fields The historical information is still returned via HISTORY paragraph of the system prompt. --- ...st_skill_results_visible_next_turn_mock.py | 12 ++--- src/loop.metta | 48 +++++++++++-------- src/memory.metta | 2 +- src/providers.py | 5 +- 4 files changed, 38 insertions(+), 29 deletions(-) diff --git a/Autotests/mock/test_last_skill_results_visible_next_turn_mock.py b/Autotests/mock/test_last_skill_results_visible_next_turn_mock.py index 63c6cb6a..56337802 100644 --- a/Autotests/mock/test_last_skill_results_visible_next_turn_mock.py +++ b/Autotests/mock/test_last_skill_results_visible_next_turn_mock.py @@ -6,7 +6,7 @@ Turn 1 mock answer dictates a metta computation. We then read the docker log to find the REQUEST line for the NEXT iteration and -confirm it contains the LAST_SKILL_USE_RESULTS marker. +confirm it contains the last tool call results marker. Run: pytest test_last_skill_results_visible_next_turn_mock.py -s @@ -72,13 +72,13 @@ def test_last_skill_results_visible_next_turn_mock(llm, comm): c.step("wait for the agent to start a fresh iteration") time.sleep(20) - c.step("verify next iteration's REQUEST contains LAST_SKILL_USE_RESULTS with sentinel") + c.step("verify next iteration's REQUEST contains tool call results with sentinel") logs = docker_logs() # We look for any REQUEST line after our metta call that carries - # the sentinel inside the LAST_SKILL_USE_RESULTS section. + # the sentinel inside the tool call results section. chars_sent_lines = [ ln for ln in logs.split("\n") - if "REQUEST:" in ln and "LAST_SKILL_USE_RESULTS" in ln + if "REQUEST:" in ln and "[TOOL CALL]" in ln ] relevant = [ ln for ln in chars_sent_lines @@ -87,7 +87,7 @@ def test_last_skill_results_visible_next_turn_mock(llm, comm): if not relevant: c.fail("sentinel in lastresults", f"no REQUEST line carries {sentinel!r} in " - f"LAST_SKILL_USE_RESULTS. Total REQUEST lines " + f"tool call results. Total REQUEST lines " f"checked: {len(chars_sent_lines)}") c.ok("sentinel in lastresults", f"found in {len(relevant)} subsequent iteration prompt(s)") @@ -97,6 +97,6 @@ def test_last_skill_results_visible_next_turn_mock(llm, comm): c.fail("no unconditional failure instruction", f"found deprecated prompt text: {deprecated_instruction!r}") c.ok("no unconditional failure instruction", - "LAST_SKILL_USE_RESULTS contains feedback without failure guidance") + "tool call results contains feedback without failure guidance") c.done() diff --git a/src/loop.metta b/src/loop.metta index bf1989df..c17481f5 100644 --- a/src/loop.metta +++ b/src/loop.metta @@ -18,7 +18,6 @@ (configure memoryDirectory (joinPath ((py-call (helper.projectRootDirectory)) memory))) (change-state! &prevmsg "") - (change-state! &lastresults "") (change-state! &loops (maxNewInputLoops)) (messagesClear) )) @@ -32,7 +31,6 @@ (string-safe (py-str ("PROMPT: " (getPrompt (provider)) " SKILLS: " (getSkillRules) (newline) (getPromptExtensions) (newline) " SAVE_PERMANENT_FILES_DIR: " (memoryDirectory) - " LAST_SKILL_USE_RESULTS: " (last_chars (get-state &lastresults) (maxFeedback)) " HISTORY: " (getHistory) " TIME: " (get_time_as_string))))) @@ -51,10 +49,15 @@ ; dynamic prompt extension placeholder to eliminate error when no extension is added (= (prompt-extension placeholder) (empty)) -(= (HandleError $msg $cmd $sexpr) - (case $sexpr (((Error $a $b) (let $new (append (get-state &error) (($msg $cmd))) - (progn (change-state! &error $new) (ALERT_FAILED $a $b)))) - ($else $sexpr)))) +(= (HandleError $result $error) + (case $result ( + ((Error $cmd $msg) (let $new (append (get-state &error) (($msg $cmd))) + (let $errorstr (swrite $result) + (progn + (change-state! &error $new) + (let $error (strings-concat ("Tool execution failed: " $errorstr)) + True))))) + ($else False)))) (= (collectTools) (py-call (providers.getTools (getSkills)))) @@ -98,27 +101,30 @@ ($_ (change-state! &nextWakeAt (+ (get_time) (wakeupInterval)))) ($_ (log INFO "loop" $lastmessage)) ($system (llmRequestMessage role "system" content $prompt)) - ($_ (messagesClear)) ($_ (messagesAppend (llmRequestMessage role "user" content $lastmessage))) ($request (llmRequest $system (messagesGet) (maxOutputToken) (reasoningMode) (collectTools))) ($_ (log INFO "loop" (REQUEST: (py-str ($request))))) ($response (llmProviderChat $request)) ($_ (log INFO "loop" (RESPONSE: (py-str ($response))))) - ($resp (py-call (providers.llmResponseToSExpr $response))) - ($sexpr (catch (sread $resp))) + ($respstr (py-call (providers.llmResponseToSExpr $response))) + ($resp (catch (sread $respstr))) ($_ (change-state! &error ())) - ($mcerr (HandleError MULTI_COMMAND_FAILURE_NOTHING_WAS_DONE_PLEASE_CORRECT_PARENTHESES_AND_USE_QUOTES_AND_RETRY $resp $sexpr)) - ($_ (log INFO "loop" (RESPONSE: $sexpr))) - ($results (if (== $mcerr $sexpr) - (RESULTS: (collapse (let $s (superpose $sexpr) - (let $precheck (HandleError UNKNOWN_SKILL_CALL $s $s) - (COMMAND_RETURN: ($s (if (== $precheck $s) - (HandleError SINGLE_COMMAND_ERROR_NOTHING_WAS_DONE_PLEASE_FIX_AND_RETRY $s (catch (let $R (eval $s) (py-call (helper.normalize_string $R))))) - $precheck))))))) - (RESULTS: $mcerr))) - ($_ (log INFO "loop" (RESPONSE: $results)))) - (progn (if (or $msgnew (not (== $sexpr ()))) (addToHistory $msg $resp $sexpr $msgnew) _) - (change-state! &lastresults (string-safe (repr $results))))) + ($_ (log INFO "loop" (RESPONSE: $resp))) + ($_ (messagesClear)) + ($_ (messagesAppend (llmRequestMessage role "assistant" + calls (llmResponseCalls $response) + content "[TOOL CALL]"))) + ($sexpr (collapse + (let ($callid $s) (superpose $resp) + (let $result + (if (HandleError $s $error) $error + (let $rc (catch (let $R (eval $s) (py-call (helper.normalize_string $R)))) + (if (HandleError $rc $error) $error (strings-concat ("SUCCESS, RETURN: " $rc))))) + (progn + ($_ (log INFO "loop" (COMMAND_RETURN: ($s $result)))) + (messagesAppend (llmRequestMessage role "tool" callid $callid content $result))i + (quote $s))))))) + (if (or $msgnew (not (== $sexpr ()))) (addToHistory $msg $sexpr $msgnew) _)) (if (> (get_time) (get-state &nextWakeAt)) (change-state! &loops (+ 1 (maxWakeLoops))) _))) (sleep (sleepInterval)) diff --git a/src/memory.metta b/src/memory.metta index c7d18698..502cd341 100644 --- a/src/memory.metta +++ b/src/memory.metta @@ -33,7 +33,7 @@ (read_file_tail $history_file (maxHistory)) ""))) -(= (addToHistory $lastmessage $response $sexpr $msgnew) +(= (addToHistory $lastmessage $response $msgnew) (if $msgnew (if (== (get-state &error) ()) (appendToHistory ((get_time_as_string) (newline) "HUMAN_MESSAGE: " $lastmessage (newline) $response (newline))) diff --git a/src/providers.py b/src/providers.py index 4d6f1176..40d33149 100644 --- a/src/providers.py +++ b/src/providers.py @@ -315,7 +315,10 @@ def llmToolCallToSExpr(call: LLMToolCall): arg = call.arguments[parameter.name] arg = arg.replace('"','\\"') sexpr = sexpr + f"\"{arg}\" " - return sexpr[:-1] + ")" + sexpr = sexpr + ")" + if call.is_error(): + sexpr = f"(Error {sexpr} \"{call.error}\")" + return f"({call.id} {sexpr})" def llmResponseToSExpr(response: LLMResponse) -> str: sexpr = "("