maki-framework 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. maki/__init__.py +57 -0
  2. maki/__main__.py +104 -0
  3. maki/agents/__init__.py +27 -0
  4. maki/agents/agent.py +388 -0
  5. maki/agents/agent_manager.py +791 -0
  6. maki/agents/agents.py +16 -0
  7. maki/agents/approvals.py +209 -0
  8. maki/agents/plugin_handler.py +582 -0
  9. maki/agents/protocols.py +49 -0
  10. maki/agents/reasoning.py +267 -0
  11. maki/agents/verification.py +174 -0
  12. maki/agents/workflow.py +271 -0
  13. maki/backend.py +155 -0
  14. maki/config.py +123 -0
  15. maki/connector.py +446 -0
  16. maki/distributed/__init__.py +16 -0
  17. maki/distributed/circuit_breaker.py +117 -0
  18. maki/distributed/config_loader.py +135 -0
  19. maki/distributed/proxy.py +346 -0
  20. maki/distributed/registry.py +89 -0
  21. maki/distributed/server.py +254 -0
  22. maki/distributed/state_store.py +230 -0
  23. maki/exceptions.py +30 -0
  24. maki/logging_config.py +39 -0
  25. maki/makiAnthropic.py +384 -0
  26. maki/makiHG.py +237 -0
  27. maki/makiLLama.py +580 -0
  28. maki/makiLlamaCpp.py +274 -0
  29. maki/makiOpenAI.py +383 -0
  30. maki/makiOpenRouter.py +147 -0
  31. maki/objects.py +389 -0
  32. maki/plugins/__init__.py +47 -0
  33. maki/plugins/_web_utils.py +100 -0
  34. maki/plugins/alpaca_data/__init__.py +3 -0
  35. maki/plugins/alpaca_data/alpaca_data.py +271 -0
  36. maki/plugins/alpaca_data/example_usage.py +34 -0
  37. maki/plugins/alpaca_data/test_alpaca_data.py +141 -0
  38. maki/plugins/alpaca_news/__init__.py +3 -0
  39. maki/plugins/alpaca_news/alpaca_news.py +184 -0
  40. maki/plugins/alpaca_news/example_usage.py +37 -0
  41. maki/plugins/alpaca_news/test_alpaca_news.py +96 -0
  42. maki/plugins/alpaca_stream/__init__.py +3 -0
  43. maki/plugins/alpaca_stream/alpaca_stream.py +152 -0
  44. maki/plugins/alpaca_stream/example_usage.py +42 -0
  45. maki/plugins/alpaca_stream/test_alpaca_stream.py +84 -0
  46. maki/plugins/alpaca_trading/__init__.py +3 -0
  47. maki/plugins/alpaca_trading/alpaca_trading.py +189 -0
  48. maki/plugins/alpaca_trading/example_usage.py +44 -0
  49. maki/plugins/alpaca_trading/test_alpaca_trading.py +24 -0
  50. maki/plugins/directory_reader/__init__.py +10 -0
  51. maki/plugins/directory_reader/directory_reader.py +239 -0
  52. maki/plugins/directory_reader/example_usage.py +41 -0
  53. maki/plugins/directory_reader/test_directory_reader.py +94 -0
  54. maki/plugins/file_reader/__init__.py +10 -0
  55. maki/plugins/file_reader/example_usage.py +58 -0
  56. maki/plugins/file_reader/file_reader.py +272 -0
  57. maki/plugins/file_reader/test_file_reader.py +74 -0
  58. maki/plugins/file_writer/__init__.py +10 -0
  59. maki/plugins/file_writer/example_usage.py +47 -0
  60. maki/plugins/file_writer/file_writer.py +332 -0
  61. maki/plugins/file_writer/test_file_writer.py +96 -0
  62. maki/plugins/ftp_client/__init__.py +10 -0
  63. maki/plugins/ftp_client/example_usage.py +116 -0
  64. maki/plugins/ftp_client/ftp_client.py +845 -0
  65. maki/plugins/ftp_client/test_ftp_client.py +251 -0
  66. maki/plugins/image_classifier/__init__.py +11 -0
  67. maki/plugins/image_classifier/example_usage.py +83 -0
  68. maki/plugins/image_classifier/image_classifier.py +116 -0
  69. maki/plugins/image_classifier/test_image_classifier.py +182 -0
  70. maki/plugins/json_reader/__init__.py +7 -0
  71. maki/plugins/json_reader/example_usage.py +18 -0
  72. maki/plugins/json_reader/json_reader.py +113 -0
  73. maki/plugins/json_reader/test_json_reader.py +37 -0
  74. maki/plugins/obsidian_memory/__init__.py +3 -0
  75. maki/plugins/obsidian_memory/example_usage.py +54 -0
  76. maki/plugins/obsidian_memory/obsidian_memory.py +221 -0
  77. maki/plugins/obsidian_memory/test_obsidian_memory.py +83 -0
  78. maki/plugins/ocr/__init__.py +10 -0
  79. maki/plugins/ocr/backends/__init__.py +17 -0
  80. maki/plugins/ocr/backends/base.py +48 -0
  81. maki/plugins/ocr/backends/docx_backend.py +69 -0
  82. maki/plugins/ocr/backends/image_backend.py +49 -0
  83. maki/plugins/ocr/backends/llm_backend.py +164 -0
  84. maki/plugins/ocr/backends/pdf_backend.py +73 -0
  85. maki/plugins/ocr/backends/xlsx_backend.py +59 -0
  86. maki/plugins/ocr/example_usage.py +79 -0
  87. maki/plugins/ocr/ocr.py +242 -0
  88. maki/plugins/ocr/test_ocr.py +273 -0
  89. maki/plugins/postgres_store/__init__.py +10 -0
  90. maki/plugins/postgres_store/example_usage.py +35 -0
  91. maki/plugins/postgres_store/postgres_store.py +271 -0
  92. maki/plugins/postgres_store/test_postgres_store.py +133 -0
  93. maki/plugins/provider_updates/__init__.py +7 -0
  94. maki/plugins/provider_updates/example_usage.py +14 -0
  95. maki/plugins/provider_updates/provider_updates.py +101 -0
  96. maki/plugins/provider_updates/test_provider_updates.py +51 -0
  97. maki/plugins/rag_memory/__init__.py +3 -0
  98. maki/plugins/rag_memory/backends/__init__.py +57 -0
  99. maki/plugins/rag_memory/backends/base.py +70 -0
  100. maki/plugins/rag_memory/backends/chroma.py +95 -0
  101. maki/plugins/rag_memory/backends/faiss_local.py +123 -0
  102. maki/plugins/rag_memory/backends/memory_numpy.py +101 -0
  103. maki/plugins/rag_memory/backends/pgvector.py +137 -0
  104. maki/plugins/rag_memory/backends/qdrant.py +108 -0
  105. maki/plugins/rag_memory/example_usage.py +71 -0
  106. maki/plugins/rag_memory/rag_memory.py +383 -0
  107. maki/plugins/rag_memory/test_rag_memory.py +275 -0
  108. maki/plugins/trend_search/__init__.py +7 -0
  109. maki/plugins/trend_search/example_usage.py +14 -0
  110. maki/plugins/trend_search/test_trend_search.py +81 -0
  111. maki/plugins/trend_search/trend_search.py +115 -0
  112. maki/plugins/web_search/__init__.py +11 -0
  113. maki/plugins/web_search/example_usage.py +48 -0
  114. maki/plugins/web_search/test_web_search.py +539 -0
  115. maki/plugins/web_search/web_search.py +699 -0
  116. maki/plugins/web_to_md/__init__.py +9 -0
  117. maki/plugins/web_to_md/example_usage.py +47 -0
  118. maki/plugins/web_to_md/test_web_to_md.py +159 -0
  119. maki/plugins/web_to_md/web_to_md.py +296 -0
  120. maki/py.typed +0 -0
  121. maki/session.py +88 -0
  122. maki/structured.py +148 -0
  123. maki/test/__init__.py +3 -0
  124. maki/test/test_agent_fixes.py +567 -0
  125. maki/test/test_agent_functionality.py +177 -0
  126. maki/test/test_agent_proxy.py +478 -0
  127. maki/test/test_agent_server.py +313 -0
  128. maki/test/test_async_rate_limiter.py +341 -0
  129. maki/test/test_backend_hierarchy.py +327 -0
  130. maki/test/test_backend_interface.py +402 -0
  131. maki/test/test_concurrency.py +509 -0
  132. maki/test/test_config_loader.py +98 -0
  133. maki/test/test_connector.py +429 -0
  134. maki/test/test_consistency_sweep.py +349 -0
  135. maki/test/test_conversation_memory.py +398 -0
  136. maki/test/test_distributed_workflow.py +560 -0
  137. maki/test/test_enhanced_workflow.py +153 -0
  138. maki/test/test_error_handling.py +171 -0
  139. maki/test/test_hardening.py +503 -0
  140. maki/test/test_history_cleanup.py +114 -0
  141. maki/test/test_logging.py +32 -0
  142. maki/test/test_makiAnthropic.py +267 -0
  143. maki/test/test_makiLLama.py +297 -0
  144. maki/test/test_makiLLama_unittest.py +252 -0
  145. maki/test/test_makiLlamaCpp.py +291 -0
  146. maki/test/test_makiOpenAI.py +255 -0
  147. maki/test/test_makiOpenRouter.py +270 -0
  148. maki/test/test_mixin_contracts.py +384 -0
  149. maki/test/test_native_tools.py +652 -0
  150. maki/test/test_plugin_structure.py +50 -0
  151. maki/test/test_security.py +705 -0
  152. maki/test/test_serialization.py +313 -0
  153. maki/test/test_session.py +65 -0
  154. maki/test/test_ssrp_protection.py +105 -0
  155. maki/test/test_structured_output.py +128 -0
  156. maki/test/test_verification_types.py +97 -0
  157. maki/test/test_verified_workflow.py +281 -0
  158. maki/test/test_workflow_approval.py +205 -0
  159. maki/utils.py +250 -0
  160. maki_framework-0.3.0.dist-info/METADATA +582 -0
  161. maki_framework-0.3.0.dist-info/RECORD +165 -0
  162. maki_framework-0.3.0.dist-info/WHEEL +5 -0
  163. maki_framework-0.3.0.dist-info/entry_points.txt +3 -0
  164. maki_framework-0.3.0.dist-info/licenses/LICENSE +21 -0
  165. maki_framework-0.3.0.dist-info/top_level.txt +1 -0
maki/__init__.py ADDED
@@ -0,0 +1,57 @@
1
+ """
2
+ Public package interface for Maki.
3
+ """
4
+
5
+ from importlib import import_module
6
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
7
+
8
+ from . import config
9
+
10
+ try:
11
+ __version__ = _pkg_version("maki-framework")
12
+ except PackageNotFoundError: # pragma: no cover - running from source, not installed
13
+ __version__ = "0.0.0.dev0"
14
+
15
+ __all__ = ["LLMBackend", "Connector", "Utils", "Agent", "AgentManager", "MakiLLama",
16
+ "HFBackend", "MakiOpenAI", "MakiAnthropic", "MakiOpenRouter", "MakiLlamaCpp",
17
+ "LLMResponse",
18
+ "GenerationConfig", "Message", "ConversationMemory", "RateLimiter", "BackendType",
19
+ "ToolCall", "config", "__version__"]
20
+
21
+ _LAZY_EXPORTS = {
22
+ "LLMBackend": (".backend", "LLMBackend"),
23
+ "Connector": (".connector", "Connector"),
24
+ "Utils": (".utils", "Utils"),
25
+ "Agent": (".agents", "Agent"),
26
+ "AgentManager": (".agents", "AgentManager"),
27
+ "MakiLLama": (".makiLLama", "MakiLLama"),
28
+ "HFBackend": (".makiHG", "HFBackend"),
29
+ "MakiOpenAI": (".makiOpenAI", "MakiOpenAI"),
30
+ "MakiAnthropic": (".makiAnthropic", "MakiAnthropic"),
31
+ "MakiOpenRouter": (".makiOpenRouter", "MakiOpenRouter"),
32
+ "MakiLlamaCpp": (".makiLlamaCpp", "MakiLlamaCpp"),
33
+ "LLMResponse": (".objects", "LLMResponse"),
34
+ "GenerationConfig": (".objects", "GenerationConfig"),
35
+ "Message": (".objects", "Message"),
36
+ "ConversationMemory": (".objects", "ConversationMemory"),
37
+ "RateLimiter": (".objects", "RateLimiter"),
38
+ "BackendType": (".objects", "BackendType"),
39
+ "ToolCall": (".objects", "ToolCall"),
40
+ }
41
+
42
+
43
+ def __getattr__(name):
44
+ """Load public exports only when they are requested."""
45
+ if name not in _LAZY_EXPORTS:
46
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
47
+
48
+ module_name, attribute_name = _LAZY_EXPORTS[name]
49
+ module = import_module(module_name, __name__)
50
+ value = getattr(module, attribute_name)
51
+ globals()[name] = value
52
+ return value
53
+
54
+
55
+ def __dir__():
56
+ """Expose lazy exports to introspection tools."""
57
+ return sorted(set(globals()) | set(__all__))
maki/__main__.py ADDED
@@ -0,0 +1,104 @@
1
+ """
2
+ Main entry point for the Maki framework CLI.
3
+
4
+ Usage:
5
+ maki Print usage help.
6
+ maki serve --config <file> [options]
7
+
8
+ serve options:
9
+ --host HOST Bind address (default: 127.0.0.1)
10
+ --port PORT Listen port (default: 8100)
11
+ --api-key KEY Bearer token for auth
12
+ --tls-cert FILE TLS certificate file (enables HTTPS)
13
+ --tls-key FILE TLS private-key file
14
+ """
15
+ import argparse
16
+ import sys
17
+
18
+ from .config import DEFAULT_MODEL, DEFAULT_OLLAMA_BASE_URL
19
+ from .logging_config import configure_logging
20
+ from .makiLLama import MakiLLama
21
+ from .agents import Agent, AgentManager
22
+
23
+ __all__ = ['MakiLLama', 'Agent', 'AgentManager']
24
+
25
+
26
+ def _cmd_serve(args: argparse.Namespace) -> None:
27
+ if args.tls_key and not args.tls_cert:
28
+ print("Error: --tls-key requires --tls-cert", file=sys.stderr)
29
+ sys.exit(1)
30
+
31
+ try:
32
+ import uvicorn
33
+ except ImportError:
34
+ print("Error: uvicorn is required. Install with: pip install 'maki-framework[distributed]'",
35
+ file=sys.stderr)
36
+ sys.exit(1)
37
+
38
+ try:
39
+ from .distributed.config_loader import load_agent_from_config
40
+ from .distributed.server import create_app
41
+ except ImportError as exc:
42
+ print(f"Error: {exc}", file=sys.stderr)
43
+ sys.exit(1)
44
+
45
+ try:
46
+ agent = load_agent_from_config(args.config)
47
+ except (FileNotFoundError, ValueError) as exc:
48
+ print(f"Error loading config: {exc}", file=sys.stderr)
49
+ sys.exit(1)
50
+
51
+ app = create_app(agent, api_key=args.api_key)
52
+
53
+ scheme = "https" if args.tls_cert else "http"
54
+ print(f"Maki Agent Server — {agent.name!r} ({agent.role or 'no role'})")
55
+ print(f" Listening on {scheme}://{args.host}:{args.port}")
56
+ print(f" Auth {'Bearer token' if args.api_key else 'disabled (trusted network)'}")
57
+ print(f" TLS {'enabled' if args.tls_cert else 'disabled'}")
58
+ print(f" Plugins {list(agent.plugins.keys()) or 'none'}")
59
+
60
+ uvicorn_kwargs: dict = {"host": args.host, "port": args.port}
61
+ if args.tls_cert:
62
+ uvicorn_kwargs["ssl_certfile"] = args.tls_cert
63
+ if args.tls_key:
64
+ uvicorn_kwargs["ssl_keyfile"] = args.tls_key
65
+ uvicorn.run(app, **uvicorn_kwargs)
66
+
67
+
68
+ def main() -> None:
69
+ parser = argparse.ArgumentParser(
70
+ prog="maki",
71
+ description="Maki framework — multi-agent LLM interactions",
72
+ )
73
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
74
+
75
+ # maki serve
76
+ serve = sub.add_parser("serve", help="Serve an agent over HTTP")
77
+ serve.add_argument("--config", required=True, metavar="FILE",
78
+ help="Path to agent YAML config file")
79
+ serve.add_argument("--host", default="127.0.0.1",
80
+ help="Bind host (default: 127.0.0.1)")
81
+ serve.add_argument("--port", type=int, default=8100,
82
+ help="Listen port (default: 8100)")
83
+ serve.add_argument("--api-key", default=None, metavar="KEY",
84
+ help="Bearer token for authentication (omit for open access)")
85
+ serve.add_argument("--tls-cert", default=None, metavar="FILE",
86
+ help="TLS certificate file — enables HTTPS when set")
87
+ serve.add_argument("--tls-key", default=None, metavar="FILE",
88
+ help="TLS private-key file (required when --tls-cert is set)")
89
+
90
+ args = parser.parse_args()
91
+ configure_logging()
92
+
93
+ if args.command == "serve":
94
+ _cmd_serve(args)
95
+ else:
96
+ parser.print_help()
97
+ print(f"\nExample:")
98
+ print(f" {DEFAULT_MODEL!r} via Ollama at {DEFAULT_OLLAMA_BASE_URL}")
99
+ print(f" from maki import MakiLLama")
100
+ print(f" from maki import Agent")
101
+
102
+
103
+ if __name__ == "__main__":
104
+ main()
@@ -0,0 +1,27 @@
1
+ """
2
+ Agent module for Maki Framework
3
+
4
+ This module provides the infrastructure for creating and managing multi-agent systems
5
+ using the Maki framework. Agents can work together to solve complex tasks through
6
+ coordination, delegation, and collaboration.
7
+ """
8
+
9
+ from .agent import Agent
10
+ from .agent_manager import AgentManager
11
+ from .workflow import WorkflowTask, WorkflowState, TaskStatus, GateResult, TaskOutput
12
+ from .approvals import (
13
+ ApprovalSpec, ApprovalStore, ApprovalRecord, ApprovalDecision,
14
+ LocalApprovalStore, InMemoryApprovalStore,
15
+ )
16
+ from .verification import (
17
+ Verdict, VerifyResult, VerifySpec, AgentVerifier, agent_verifier,
18
+ )
19
+ from .protocols import PluginHostProtocol, ReasoningHostProtocol
20
+
21
+ __all__ = [
22
+ 'Agent', 'AgentManager', 'WorkflowTask', 'TaskStatus', 'WorkflowState',
23
+ 'PluginHostProtocol', 'ReasoningHostProtocol',
24
+ 'GateResult', 'TaskOutput', 'ApprovalSpec', 'ApprovalStore', 'ApprovalRecord', 'ApprovalDecision',
25
+ 'LocalApprovalStore', 'InMemoryApprovalStore',
26
+ 'Verdict', 'VerifyResult', 'VerifySpec', 'AgentVerifier', 'agent_verifier',
27
+ ]
maki/agents/agent.py ADDED
@@ -0,0 +1,388 @@
1
+ """
2
+ Core Agent class for the Maki Framework.
3
+
4
+ An Agent wraps a Maki LLM backend and provides task execution, memory,
5
+ stateful conversations, and extension points via the PluginHandler and
6
+ ReasoningEngine mixins.
7
+ """
8
+
9
+ from collections import deque
10
+ from typing import Dict, Any, Optional
11
+ import json
12
+ import logging
13
+ import threading
14
+ import time
15
+ import uuid
16
+
17
+ from ..backend import LLMBackend
18
+ from ..exceptions import MakiError, MakiNetworkError, MakiTimeoutError, MakiAPIError
19
+ from ..objects import ConversationMemory, Message
20
+ from .plugin_handler import PluginHandler
21
+ from .reasoning import ReasoningEngine
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # Errors that are worth retrying (transient network/timeout issues)
26
+ _RETRYABLE_ERRORS = (MakiNetworkError, MakiTimeoutError)
27
+
28
+
29
+ class Agent(PluginHandler, ReasoningEngine):
30
+ """An individual agent that can perform tasks using the Maki framework."""
31
+
32
+ def __init__(self, name: str, maki_instance: LLMBackend, role: str = "", instructions: str = "",
33
+ stateful: bool = False, use_streaming: bool = False,
34
+ allow_dangerous_tools: bool = False):
35
+ """
36
+ Initialize an agent.
37
+
38
+ Args:
39
+ name: Unique identifier for the agent
40
+ maki_instance: Maki instance to use for LLM interactions
41
+ role: The role of the agent (e.g., "researcher", "writer", "analyst")
42
+ instructions: Specific instructions for this agent
43
+ stateful: If True, prior task results are included in subsequent prompts
44
+ use_streaming: If True, execute_task uses streaming internally (chat_collect)
45
+ so the timeout applies per-chunk rather than to the full response.
46
+ Useful for tasks with very long outputs that exceed the backend timeout.
47
+ allow_dangerous_tools: If True, plugin methods marked in a plugin's
48
+ DANGEROUS_METHODS (file writes, uploads, deletes, …) may be
49
+ invoked via TOOL: directives. Off by default — a prompt-injected
50
+ instruction in scraped content should not be able to write
51
+ files or delete remote directories.
52
+
53
+ Raises:
54
+ ValueError: If name is not a valid string
55
+ TypeError: If maki_instance is not a Maki instance
56
+ """
57
+ if not isinstance(name, str) or not name.strip():
58
+ raise ValueError("Agent name must be a non-empty string")
59
+
60
+ if not isinstance(role, str):
61
+ raise ValueError("Role must be a string")
62
+
63
+ if not isinstance(instructions, str):
64
+ raise ValueError("Instructions must be a string")
65
+
66
+ if not isinstance(maki_instance, LLMBackend) and not hasattr(maki_instance, 'request'):
67
+ raise TypeError("maki_instance must implement the LLMBackend interface")
68
+
69
+ self.agent_id = str(uuid.uuid4())
70
+ self.name = name.strip()
71
+ self.maki = maki_instance
72
+ self.role = role
73
+ self.instructions = instructions
74
+ self.stateful = stateful
75
+ self.use_streaming = use_streaming
76
+ self.allow_dangerous_tools = allow_dangerous_tools
77
+ self.memory = {}
78
+
79
+ # Maximum number of entries to keep in history; deque enforces this automatically
80
+ self._max_history_entries = 1000
81
+ self.reasoning_history: deque = deque(maxlen=self._max_history_entries)
82
+ self.task_history: deque = deque(maxlen=self._max_history_entries)
83
+
84
+ # Stateful multi-turn conversation memory (separate from task_history).
85
+ # Token-budgeted and eviction-based; replaces the old fixed-window + 300-char
86
+ # truncation approach.
87
+ self._conversation_memory = ConversationMemory(
88
+ max_entries=self._max_history_entries,
89
+ )
90
+
91
+ # One in-flight task at a time; acquired by execute_task / stream_task so
92
+ # concurrent callers (workflow thread pools, FastAPI handlers) are serialized.
93
+ self._execution_lock = threading.Lock()
94
+
95
+ # Validate and initialise mixin contracts (must come after all attrs are set).
96
+ self._init_reasoning()
97
+ self._init_plugins()
98
+
99
+ logger.info(f"Agent '{self.name}' initialized with role '{self.role}'")
100
+
101
+ def __repr__(self):
102
+ return f"Agent(name='{self.name}', role='{self.role}')"
103
+
104
+ def _build_history_section(self) -> str:
105
+ """Return the prior-conversation block for stateful prompts, or empty string."""
106
+ if not self.stateful:
107
+ return ""
108
+ return self._conversation_memory.format_as_text()
109
+
110
+ def _build_system_message(self) -> str:
111
+ """Return the system message (role + instructions) for this agent."""
112
+ return f"You are {self.name}, a {self.role}. {self.instructions}".strip()
113
+
114
+ def _build_user_message(self, task: str, context: Optional[Dict], use_plugins: bool) -> str:
115
+ """Return the user message (task + optional context/plugins/history)."""
116
+ plugin_section = self.build_plugin_prompt_section() if use_plugins and self.plugins else ""
117
+ history_section = self._build_history_section()
118
+ parts = [task]
119
+ if context:
120
+ parts.append(f"Context: {json.dumps(context)}")
121
+ if plugin_section:
122
+ parts.append(plugin_section)
123
+ if history_section:
124
+ parts.append(history_section)
125
+ return "\n\n".join(p for p in parts if p)
126
+
127
+ def execute_task(self, task: str, context: Optional[Dict] = None, use_plugins: bool = False) -> str:
128
+ """
129
+ Execute a task using this agent.
130
+
131
+ Args:
132
+ task: The task to perform
133
+ context: Additional context for the task
134
+ use_plugins: When True and plugins are loaded, their descriptions are included
135
+ in the prompt and the LLM may issue TOOL: calls that are executed before
136
+ the final response is synthesised.
137
+
138
+ Returns:
139
+ The result of the task execution
140
+
141
+ Raises:
142
+ ValueError: If task is not a valid string
143
+ MakiNetworkError: For network-related errors
144
+ MakiTimeoutError: For timeout errors
145
+ MakiAPIError: For API response errors
146
+ """
147
+ if not isinstance(task, str) or not task.strip():
148
+ raise ValueError("Task must be a non-empty string")
149
+
150
+ with self._execution_lock:
151
+ try:
152
+ logger.debug(f"Executing task '{task}' for agent '{self.name}'")
153
+ start_time = time.time()
154
+ system_msg = self._build_system_message()
155
+
156
+ if use_plugins and self.plugins and getattr(self.maki, "supports_native_tools", False) is True:
157
+ # Native tool-calling path: backend drives structured tool calls.
158
+ result = self.execute_with_native_tools(task, context, system_msg)
159
+ else:
160
+ user_msg = self._build_user_message(task, context, use_plugins)
161
+ if self.use_streaming:
162
+ result = self.maki.chat_collect(user_msg, system=system_msg).content
163
+ else:
164
+ result = self.maki.chat(user_msg, system=system_msg).content
165
+ if result is None:
166
+ raise MakiAPIError(f"Backend returned None content for task '{task}'")
167
+
168
+ # Legacy TOOL: directive path for non-native backends.
169
+ if use_plugins and self.plugins:
170
+ result = self.handle_plugin_calls(result, task, context)
171
+
172
+ execution_time = time.time() - start_time
173
+ logger.debug(f"Task '{task}' completed in {execution_time:.2f}s for agent '{self.name}'")
174
+ except (MakiError, ValueError, TypeError) as e:
175
+ logger.error(f"Failed to execute task '{task}' for agent '{self.name}': {str(e)}", exc_info=True)
176
+ raise
177
+ except Exception as e:
178
+ logger.error(f"Unexpected error executing task '{task}' for agent '{self.name}': {str(e)}", exc_info=True)
179
+ raise MakiError(f"Failed to execute task '{task}' for agent '{self.name}': {str(e)}") from e
180
+
181
+ # Record the task execution in history
182
+ self.task_history.append({
183
+ 'task': task,
184
+ 'context': context,
185
+ 'result': result,
186
+ 'timestamp': time.time()
187
+ })
188
+
189
+ # Maintain stateful conversation history
190
+ if self.stateful:
191
+ self._conversation_memory.append(Message("user", task))
192
+ self._conversation_memory.append(Message("assistant", result))
193
+
194
+ return result
195
+
196
+ def reset_conversation(self):
197
+ """Clear the stateful conversation history."""
198
+ self._conversation_memory.clear()
199
+
200
+ def execute_task_with_retry(self, task: str, context: Optional[Dict] = None,
201
+ max_retries: int = 3, retry_delay: float = 1.0,
202
+ use_plugins: bool = False) -> str:
203
+ """
204
+ Execute a task with retry logic.
205
+
206
+ Only transient network/timeout errors are retried. Non-retryable errors
207
+ (MakiAPIError, ValueError, etc.) are re-raised immediately without waiting.
208
+
209
+ Args:
210
+ task: The task to perform
211
+ context: Additional context for the task
212
+ max_retries: Maximum number of retry attempts
213
+ retry_delay: Delay between retries in seconds
214
+
215
+ Returns:
216
+ The result of the task execution
217
+
218
+ Raises:
219
+ Exception: If task fails after all retries (transient) or immediately (non-transient)
220
+ """
221
+ for attempt in range(max_retries):
222
+ try:
223
+ return self.execute_task(task, context, use_plugins=use_plugins)
224
+ except _RETRYABLE_ERRORS as e:
225
+ if attempt == max_retries - 1:
226
+ raise
227
+ logger.warning(
228
+ f"Task '{task}' failed on attempt {attempt + 1}/{max_retries}: "
229
+ f"{str(e)}. Retrying in {retry_delay}s..."
230
+ )
231
+ time.sleep(retry_delay)
232
+ except Exception:
233
+ # Non-retryable (API error, bad input, etc.) -- fail fast
234
+ raise
235
+
236
+ # Unreachable, but satisfies type checkers
237
+ raise RuntimeError(f"Task '{task}' failed after {max_retries} attempts")
238
+
239
+ def stream_task(self, task: str, context: Optional[Dict] = None, use_plugins: bool = False):
240
+ """
241
+ Stream a task response token by token.
242
+
243
+ Requires a backend that supports streaming (e.g., MakiLLama). Raises
244
+ NotImplementedError if the configured backend has no stream() method.
245
+
246
+ History (task_history and stateful conversation history) is recorded
247
+ via a finally block in the returned generator, so it is always updated
248
+ regardless of whether the caller consumes the stream fully or abandons
249
+ it early.
250
+
251
+ Args:
252
+ task: The task to perform
253
+ context: Additional context for the task
254
+ use_plugins: When True and plugins are loaded, plugin descriptions
255
+ are included in the prompt. Note: TOOL: directives emitted
256
+ during streaming are not executed mid-stream; use execute_task()
257
+ if you need plugin call/response cycles.
258
+
259
+ Returns:
260
+ A generator that yields response chunks
261
+ """
262
+ if not isinstance(task, str) or not task.strip():
263
+ raise ValueError("Task must be a non-empty string")
264
+
265
+ system_msg = self._build_system_message()
266
+ user_msg = self._build_user_message(task, context, use_plugins)
267
+ stream_kwargs = {"system": system_msg}
268
+
269
+ def _tracked():
270
+ chunks = []
271
+ with self._execution_lock:
272
+ try:
273
+ raw_stream = self.maki.stream(user_msg, **stream_kwargs)
274
+ except NotImplementedError as e:
275
+ raise NotImplementedError(
276
+ f"Backend '{type(self.maki).__name__}' does not support streaming. "
277
+ "Use MakiLLama or another streaming-capable backend instead."
278
+ ) from e
279
+ try:
280
+ for chunk in raw_stream:
281
+ chunks.append(chunk)
282
+ yield chunk
283
+ finally:
284
+ # Always record whatever was produced, even on early abandonment.
285
+ if chunks:
286
+ full_result = "".join(chunks)
287
+ self.task_history.append({
288
+ 'task': task,
289
+ 'context': context,
290
+ 'result': full_result,
291
+ 'timestamp': time.time(),
292
+ })
293
+ if self.stateful:
294
+ self._conversation_memory.append(Message("user", task))
295
+ self._conversation_memory.append(Message("assistant", full_result))
296
+
297
+ return _tracked()
298
+
299
+ def remember(self, key: str, value: Any):
300
+ """Store information in the agent's memory."""
301
+ self.memory[key] = value
302
+
303
+ def recall(self, key: str) -> Any:
304
+ """Retrieve information from the agent's memory."""
305
+ return self.memory.get(key, None)
306
+
307
+ def clear_memory(self):
308
+ """Clear the agent's memory."""
309
+ self.memory.clear()
310
+
311
+ def to_dict(self) -> dict:
312
+ """Serialize the agent's state to a JSON-compatible dict.
313
+
314
+ The LLM backend is not included — pass it explicitly to from_dict().
315
+ """
316
+ return {
317
+ 'agent_id': self.agent_id,
318
+ 'name': self.name,
319
+ 'role': self.role,
320
+ 'instructions': self.instructions,
321
+ 'stateful': self.stateful,
322
+ 'use_streaming': self.use_streaming,
323
+ 'memory': dict(self.memory),
324
+ 'task_history': list(self.task_history),
325
+ 'conversation_memory': self._conversation_memory.to_list(),
326
+ 'conversation_token_budget': self._conversation_memory.token_budget,
327
+ 'max_history_entries': self._max_history_entries,
328
+ }
329
+
330
+ @classmethod
331
+ def from_dict(cls, data: dict, maki_instance: LLMBackend) -> 'Agent':
332
+ """Reconstruct an Agent from a dict produced by to_dict().
333
+
334
+ Args:
335
+ data: Dict previously returned by to_dict().
336
+ maki_instance: LLM backend to attach (not serialized).
337
+ """
338
+ agent = cls(
339
+ name=data['name'],
340
+ maki_instance=maki_instance,
341
+ role=data.get('role', ''),
342
+ instructions=data.get('instructions', ''),
343
+ stateful=data.get('stateful', False),
344
+ use_streaming=data.get('use_streaming', False),
345
+ )
346
+ agent.agent_id = data['agent_id']
347
+ agent.memory = dict(data.get('memory', {}))
348
+ max_entries = data.get('max_history_entries', 1000)
349
+ agent._max_history_entries = max_entries
350
+ agent.task_history = deque(data.get('task_history', []), maxlen=max_entries)
351
+ token_budget = data.get('conversation_token_budget', ConversationMemory.DEFAULT_TOKEN_BUDGET)
352
+ if 'conversation_memory' in data:
353
+ agent._conversation_memory = ConversationMemory.from_list(
354
+ data['conversation_memory'],
355
+ token_budget=token_budget,
356
+ max_entries=max_entries,
357
+ )
358
+ elif 'conversation_history' in data:
359
+ # Migration from old {'task': ..., 'result': ...} format
360
+ agent._conversation_memory = ConversationMemory(
361
+ token_budget=token_budget, max_entries=max_entries,
362
+ )
363
+ for turn in data['conversation_history']:
364
+ agent._conversation_memory._messages.append(Message('user', turn.get('task', '')))
365
+ agent._conversation_memory._messages.append(Message('assistant', turn.get('result', '')))
366
+ else:
367
+ agent._conversation_memory = ConversationMemory(
368
+ token_budget=token_budget, max_entries=max_entries,
369
+ )
370
+ return agent
371
+
372
+ def set_max_history_entries(self, max_entries: int):
373
+ """Set the maximum number of entries to keep in history.
374
+
375
+ Existing history is preserved up to the new limit (most recent entries kept).
376
+
377
+ Args:
378
+ max_entries: Maximum number of entries to keep in history
379
+ """
380
+ if max_entries <= 0:
381
+ raise ValueError("max_entries must be positive")
382
+ self._max_history_entries = max_entries
383
+ # Recreate deques with new maxlen, preserving most recent entries
384
+ self.reasoning_history = deque(self.reasoning_history, maxlen=max_entries)
385
+ self.task_history = deque(self.task_history, maxlen=max_entries)
386
+ # max_entries setter trims immediately if the new cap is smaller
387
+ self._conversation_memory.max_entries = max(2, max_entries)
388
+