worker-agent 1.0.3__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.
config.yaml ADDED
@@ -0,0 +1,54 @@
1
+ # =============================================================================
2
+ # Worker Agent Configuration
3
+ # Clone this file to create a new specialized worker — no code changes needed.
4
+ # =============================================================================
5
+
6
+ agent:
7
+ name: "WorkerAgent"
8
+ version: "1.0.0"
9
+ description: |
10
+ Run the Worker Agent's internal ReAct loop to complete a sub-task.
11
+ Args:
12
+ instruction: A clear, self-contained description of the task to perform.
13
+
14
+ Returns:
15
+ The final result produced by the agent after it has finished reasoning
16
+ and using its tools. A log file path is appended for traceability.
17
+
18
+ system_prompt: |
19
+ You are a diligent, autonomous worker agent.
20
+ Your job is to complete the sub-task given to you as precisely and efficiently as possible.
21
+ You have access to a set of tools. Think step-by-step, use the tools you need,
22
+ self-correct on errors, and only respond once the task is fully complete.
23
+ Do NOT ask for clarification — make your best effort with the information provided.
24
+
25
+ model:
26
+ provider: "ollama" # Supported: "ollama", "openai", "gemini"
27
+ model_name: "qwen3-coder:480b-cloud"
28
+ temperature: 0.0
29
+ base_url: "http://localhost:11434" # Uncomment if using openai or gemini (or set API_KEY env var) https://api.openai.com/v1 ollma local (http://localhost:11434)
30
+ # api_key: "your-api-key-here" # Uncomment if using openai or gemini (or set API_KEY env var)
31
+
32
+ # MCP Servers this worker connects TO as a CLIENT to get its tools.
33
+ # Each entry starts a subprocess and connects via stdio.
34
+ mcp_clients:
35
+ # - name: "filesystem-server"
36
+ # command: "npx"
37
+ # args: ["-y", "@modelcontextprotocol/server-filesystem", "C:/Users/Dev/Project"]
38
+ #
39
+ # - name: "brave-search"
40
+ # command: "npx"
41
+ # args: ["-y", "@modelcontextprotocol/server-brave-search"]
42
+ #
43
+ - name: "shell_execution"
44
+ command: "D:\\DEV\\ML\\Seeker\\.venv\\Scripts\\python.exe"
45
+ args: ["-m", "mcp_server_shell", "--shell", "powershell"]
46
+ env:
47
+ CWD: "D:\\DEV\\ML\\Seeker"
48
+
49
+ # How this worker exposes ITSELF as an MCP server.
50
+ server:
51
+ name: "worker-agent-server"
52
+ port: 8001 # HTTP/SSE transport port
53
+ transport: "stdio" # "stdio" or "sse"
54
+ host: "0.0.0.0"
core/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """
2
+ core/__init__.py
3
+ """
core/agent.py ADDED
@@ -0,0 +1,344 @@
1
+ """
2
+ core/agent.py
3
+ -------------
4
+ The heart of the Worker Agent.
5
+
6
+ Responsibilities:
7
+ 1. Read config to know which MCP servers to connect to.
8
+ 2. Dynamically load all tools from those MCP servers (connections stay alive).
9
+ 3. Build a LangGraph ReAct loop with those tools + the local Ollama LLM.
10
+ 4. Log every single step to a per-job file via JobLogger.
11
+ 5. Expose run_agent(task, config) -> str used by main.py.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import logging
18
+ import traceback
19
+ import warnings
20
+ from contextlib import AsyncExitStack
21
+
22
+ # Suppress LangGraph deprecation noise
23
+ warnings.filterwarnings("ignore", category=DeprecationWarning)
24
+
25
+ from langchain_core.messages import (
26
+ AIMessage,
27
+ HumanMessage,
28
+ SystemMessage,
29
+ ToolMessage,
30
+ )
31
+ from langchain_core.tools import BaseTool
32
+ from langchain_ollama import ChatOllama
33
+
34
+ try:
35
+ from langchain_openai import ChatOpenAI
36
+ except ImportError:
37
+ pass
38
+ try:
39
+ from langchain_google_genai import ChatGoogleGenerativeAI
40
+ except ImportError:
41
+ pass
42
+ from langchain_mcp_adapters.tools import load_mcp_tools
43
+ from langgraph.prebuilt import create_react_agent
44
+ from mcp import ClientSession, StdioServerParameters
45
+ from mcp.client.stdio import stdio_client
46
+
47
+ from core.config_loader import AppConfig
48
+ from core.job_logger import JobLogger
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Internal helpers
55
+ # ---------------------------------------------------------------------------
56
+
57
+
58
+ def _tool_input(tool_call: dict) -> dict:
59
+ """Extract input args from a LangChain tool call dict."""
60
+ return tool_call.get("args", {}) or {}
61
+
62
+
63
+ def _truncate(text: str, max_chars: int = 800) -> str:
64
+ if len(text) <= max_chars:
65
+ return text
66
+ return text[:max_chars] + f"\n... [{len(text) - max_chars} chars truncated]"
67
+
68
+
69
+ def _unwrap_exception(exc: BaseException) -> BaseException:
70
+ """Recursively unwrap ExceptionGroups to find the underlying root cause."""
71
+ # Check if this is an ExceptionGroup (Python 3.11+ built-in)
72
+ if hasattr(exc, "exceptions") and exc.exceptions:
73
+ # We recursively unwrap the first exception in the group.
74
+ # This assumes the most interesting error is the first one (often true for simple TaskGroups).
75
+ return _unwrap_exception(exc.exceptions[0])
76
+ return exc
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Main entry-point — called by main.py for every execute_task() invocation
81
+ # ---------------------------------------------------------------------------
82
+
83
+
84
+ async def run_agent(task: str, config: AppConfig) -> str:
85
+ """
86
+ Execute a task using the full ReAct loop with per-job structured logging.
87
+
88
+ - One JobLogger (= one log file) is created per call.
89
+ - All MCP connections remain open for the entire loop duration.
90
+ - Every LLM output, tool call, tool result and error is logged as a step.
91
+
92
+ Args:
93
+ task: The natural-language instruction for this worker.
94
+ config: The loaded AppConfig from config.yaml.
95
+
96
+ Returns:
97
+ str: The final answer produced by the agent.
98
+ """
99
+ jl = JobLogger(task=task, agent_name=config.agent.name)
100
+ logger.info("[%s] Job %s started | task: %s", config.agent.name, jl.job_id, task[:80])
101
+
102
+ all_tools: list[BaseTool] = []
103
+ final_answer = ""
104
+ success = False
105
+
106
+ try:
107
+ # ----------------------------------------------------------------
108
+ # PHASE 1 — Connect to all MCP tool servers
109
+ # ----------------------------------------------------------------
110
+ async with AsyncExitStack() as stack:
111
+ for client_cfg in config.mcp_clients:
112
+ server_params = StdioServerParameters(
113
+ command=client_cfg.command,
114
+ args=client_cfg.args,
115
+ env=client_cfg.env or None,
116
+ )
117
+ try:
118
+ read, write = await stack.enter_async_context(stdio_client(server_params))
119
+ session: ClientSession = await stack.enter_async_context(
120
+ ClientSession(read, write)
121
+ )
122
+ await session.initialize()
123
+ tools = await load_mcp_tools(session)
124
+ tool_names = [t.name for t in tools]
125
+ all_tools.extend(tools)
126
+
127
+ jl.log_step(
128
+ step_type="MCP_CONNECT",
129
+ title=client_cfg.name,
130
+ details={
131
+ "command": client_cfg.command,
132
+ "args": client_cfg.args,
133
+ "tools_loaded": tool_names,
134
+ },
135
+ success=True,
136
+ )
137
+ logger.info("[%s] Connected | tools: %s", client_cfg.name, tool_names)
138
+
139
+ except Exception as exc:
140
+ tb = traceback.format_exc()
141
+ jl.log_step(
142
+ step_type="MCP_CONNECT",
143
+ title=client_cfg.name,
144
+ details={"command": client_cfg.command, "args": client_cfg.args},
145
+ error=f"{exc}\n{tb}",
146
+ success=False,
147
+ )
148
+ logger.error("[%s] Connection failed: %s", client_cfg.name, exc)
149
+
150
+ if not all_tools:
151
+ jl.log_step(
152
+ step_type="INFO",
153
+ title="No tools available",
154
+ details={
155
+ "note": "Running with LLM only (no MCP tools configured or all failed to connect)."
156
+ },
157
+ )
158
+
159
+ # ----------------------------------------------------------------
160
+ # PHASE 2 — Build the ReAct agent
161
+ # ----------------------------------------------------------------
162
+ tool_descriptions = "\n".join(
163
+ f" - {t.name}: {getattr(t, 'description', 'no description')}" for t in all_tools
164
+ )
165
+ enriched_prompt = config.agent.system_prompt + (
166
+ f"\n\nAvailable tools:\n{tool_descriptions}" if tool_descriptions else ""
167
+ )
168
+
169
+ provider = config.model.provider.lower()
170
+ if provider == "openai":
171
+ llm = ChatOpenAI(
172
+ model=config.model.model_name,
173
+ temperature=config.model.temperature,
174
+ api_key=config.model.api_key,
175
+ base_url=config.model.base_url
176
+ if config.model.base_url != "http://localhost:11434"
177
+ else None,
178
+ )
179
+ elif provider == "gemini":
180
+ llm = ChatGoogleGenerativeAI(
181
+ model=config.model.model_name,
182
+ temperature=config.model.temperature,
183
+ api_key=config.model.api_key,
184
+ )
185
+ else:
186
+ # default to ollama
187
+ llm = ChatOllama(
188
+ model=config.model.model_name,
189
+ temperature=config.model.temperature,
190
+ base_url=config.model.base_url,
191
+ )
192
+ graph = create_react_agent(model=llm, tools=all_tools)
193
+
194
+ jl.log_step(
195
+ step_type="AGENT_INIT",
196
+ title="LangGraph ReAct agent ready",
197
+ details={
198
+ "model": config.model.model_name,
199
+ "temperature": config.model.temperature,
200
+ "tools": [t.name for t in all_tools],
201
+ },
202
+ )
203
+
204
+ # ----------------------------------------------------------------
205
+ # PHASE 3 — Run the ReAct loop + log every event
206
+ # ----------------------------------------------------------------
207
+ messages = [
208
+ SystemMessage(content=enriched_prompt),
209
+ HumanMessage(content=task),
210
+ ]
211
+
212
+ # Track which tool calls we've already logged (by tool call id)
213
+ _logged_tool_calls: set = set()
214
+ _llm_step = 0
215
+
216
+ async for event in graph.astream(
217
+ {"messages": messages},
218
+ stream_mode="values",
219
+ ):
220
+ last_msg = event["messages"][-1]
221
+
222
+ # ── AIMessage: LLM produced text or tool-call plan ────────
223
+ if isinstance(last_msg, AIMessage):
224
+ tool_calls = getattr(last_msg, "tool_calls", []) or []
225
+
226
+ # Log the text part of the LLM response (if any)
227
+ if last_msg.content:
228
+ _llm_step += 1
229
+
230
+ # Handle list-based content (e.g., from Gemini's multimodal format)
231
+ content_val = last_msg.content
232
+ if isinstance(content_val, list):
233
+ content_val = "\n".join(
234
+ b.get("text", str(b)) if isinstance(b, dict) else str(b)
235
+ for b in content_val
236
+ )
237
+ elif not isinstance(content_val, str):
238
+ content_val = str(content_val)
239
+
240
+ jl.log_step(
241
+ step_type="LLM_RESPONSE",
242
+ title=f"LLM turn {_llm_step}",
243
+ output=_truncate(content_val),
244
+ )
245
+ final_answer = content_val
246
+
247
+ # Log each tool call the LLM decided to make
248
+ for tc in tool_calls:
249
+ tc_id = tc.get("id", "")
250
+ if tc_id in _logged_tool_calls:
251
+ continue
252
+ _logged_tool_calls.add(tc_id)
253
+ jl.log_step(
254
+ step_type="TOOL_CALL",
255
+ title=tc.get("name", "unknown"),
256
+ details={
257
+ "tool": tc.get("name"),
258
+ "call_id": tc_id,
259
+ "input": _tool_input(tc),
260
+ },
261
+ )
262
+
263
+ # ── ToolMessage: result came back from a tool ─────────────
264
+ elif isinstance(last_msg, ToolMessage):
265
+ raw_content = last_msg.content or ""
266
+ # Detect error by checking for non-zero return codes or exception text
267
+ is_error = (
268
+ "error" in str(raw_content).lower()
269
+ or "exception" in str(raw_content).lower()
270
+ or "traceback" in str(raw_content).lower()
271
+ )
272
+ jl.log_step(
273
+ step_type="TOOL_RESULT",
274
+ title=getattr(last_msg, "name", "tool") or "tool",
275
+ details={"call_id": getattr(last_msg, "tool_call_id", "")},
276
+ output=_truncate(str(raw_content)),
277
+ success=not is_error,
278
+ error=str(raw_content) if is_error else None,
279
+ )
280
+
281
+ # ── Done ──────────────────────────────────────────────────────
282
+ success = True
283
+
284
+ except BaseException as root_exc:
285
+ # We catch BaseException so we can catch BaseExceptionGroup
286
+ exc = _unwrap_exception(root_exc)
287
+ tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
288
+
289
+ # Don't log normal cancellations as fatal errors
290
+ if isinstance(exc, (asyncio.CancelledError, KeyboardInterrupt)):
291
+ logger.warning(
292
+ "[%s] Job %s was cancelled or interrupted.", config.agent.name, jl.job_id
293
+ )
294
+ final_answer = "ERROR: Agent task was cancelled or interrupted."
295
+ success = False
296
+
297
+ # Gracefully log familiar LLM provider errors without tracebacks
298
+ elif type(exc).__name__ in (
299
+ "RateLimitError",
300
+ "AuthenticationError",
301
+ "APIConnectionError",
302
+ "APIError",
303
+ "InvalidRequestError",
304
+ ):
305
+ jl.log_step(
306
+ step_type="LLM_API_ERROR",
307
+ title=type(exc).__name__,
308
+ error=f"{exc}",
309
+ success=False,
310
+ )
311
+ logger.error(
312
+ "[%s] Job %s failed with LLM provider API error: %s",
313
+ config.agent.name,
314
+ jl.job_id,
315
+ exc,
316
+ )
317
+ final_answer = f"ERROR: LLM Provider Issue ({type(exc).__name__}): {exc}"
318
+ success = False
319
+
320
+ # All other unhandled exceptions get the dreaded Traceback log
321
+ else:
322
+ jl.log_step(
323
+ step_type="FATAL_ERROR",
324
+ title=type(exc).__name__,
325
+ error=f"{exc}\n\n{tb}",
326
+ success=False,
327
+ )
328
+ logger.exception(
329
+ "[%s] Job %s failed with unhandled exception: %s", config.agent.name, jl.job_id, exc
330
+ )
331
+ final_answer = f"ERROR: {type(exc).__name__}: {exc}"
332
+ success = False
333
+
334
+ finally:
335
+ jl.finish(final_answer=final_answer, success=success)
336
+ logger.info(
337
+ "[%s] Job %s %s | log: %s",
338
+ config.agent.name,
339
+ jl.job_id,
340
+ "COMPLETE" if success else "FAILED",
341
+ jl.path,
342
+ )
343
+
344
+ return final_answer or "Agent completed the task but produced no text output."
core/config_loader.py ADDED
@@ -0,0 +1,159 @@
1
+ """
2
+ core/config_loader.py
3
+ ---------------------
4
+ Loads and validates config.yaml into typed dataclasses.
5
+ Import and use `load_config()` anywhere in the project.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+ import yaml
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Dataclass models
18
+ # ---------------------------------------------------------------------------
19
+
20
+
21
+ @dataclass
22
+ class ModelConfig:
23
+ provider: str = "ollama" # e.g., "ollama", "openai", "gemini"
24
+ model_name: str = "llama3.2"
25
+ temperature: float = 0.0
26
+ base_url: str = "http://localhost:11434"
27
+ api_key: str | None = None
28
+
29
+
30
+ @dataclass
31
+ class MCPClientConfig:
32
+ """Represents one external MCP server this worker connects to as a client."""
33
+
34
+ name: str
35
+ command: str
36
+ args: list[str] = field(default_factory=list)
37
+ env: dict = field(default_factory=dict) # optional extra env vars
38
+
39
+
40
+ @dataclass
41
+ class ServerConfig:
42
+ name: str = "worker-agent-server"
43
+ port: int = 8001
44
+ transport: str = "stdio" # "stdio" | "sse"
45
+ host: str = "0.0.0.0"
46
+
47
+
48
+ DEFAULT_DESCRIPTION = (
49
+ "Run the Worker Agent's internal ReAct loop to complete a sub-task.\n"
50
+ "Args:\n"
51
+ " instruction: A clear, self-contained description of the task to perform.\n\n"
52
+ "Returns:\n"
53
+ " The final result produced by the agent after it has finished reasoning\n"
54
+ " and using its tools. A log file path is appended for traceability."
55
+ )
56
+
57
+
58
+ @dataclass
59
+ class AgentConfig:
60
+ name: str = "WorkerAgent"
61
+ version: str = "1.0.0"
62
+ description: str = DEFAULT_DESCRIPTION
63
+ system_prompt: str = "You are a helpful worker agent."
64
+
65
+
66
+ @dataclass
67
+ class AppConfig:
68
+ agent: AgentConfig = field(default_factory=AgentConfig)
69
+ model: ModelConfig = field(default_factory=ModelConfig)
70
+ mcp_clients: list[MCPClientConfig] = field(default_factory=list)
71
+ server: ServerConfig = field(default_factory=ServerConfig)
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Loader
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ def load_config(config_path: str | None = None) -> AppConfig:
80
+ """
81
+ Priority:
82
+ 1. config_path (passed to function)
83
+ 2. WORKER_AGENT_CONFIG (environment variable)
84
+ 3. ./config.yaml (Current Working Directory)
85
+ 4. ../config.yaml (Package Root fallback)
86
+ """
87
+
88
+ # Define potential locations
89
+ env_path = os.getenv("WORKER_AGENT_CONFIG")
90
+ cwd_path = Path.cwd() / "config.yaml"
91
+ package_root_path = Path(__file__).parent.parent / "config.yaml"
92
+
93
+ # Select the first one that exists
94
+ if config_path:
95
+ final_path = Path(config_path)
96
+ elif env_path:
97
+ final_path = Path(env_path)
98
+ elif cwd_path.exists():
99
+ final_path = cwd_path
100
+ else:
101
+ final_path = package_root_path
102
+
103
+ # Final check
104
+ if not final_path.exists():
105
+ raise FileNotFoundError(
106
+ f"Config file not found. Checked:\n"
107
+ f"- Explicit path: {config_path}\n"
108
+ f"- Env Var (WORKER_AGENT_CONFIG): {env_path}\n"
109
+ f"- Current Directory: {cwd_path}\n"
110
+ f"- Package Fallback: {package_root_path}\n"
111
+ f"Please ensure a 'config.yaml' exists in your current folder."
112
+ )
113
+
114
+ print(f"[*] Using config: {final_path.absolute()}")
115
+
116
+ with open(final_path, encoding="utf-8") as f:
117
+ raw = yaml.safe_load(f) or {}
118
+
119
+ # --- Agent ---
120
+ agent_raw = raw.get("agent", {})
121
+ agent = AgentConfig(
122
+ name=agent_raw.get("name", "WorkerAgent"),
123
+ version=agent_raw.get("version", "1.0.0"),
124
+ description=agent_raw.get("description", DEFAULT_DESCRIPTION),
125
+ system_prompt=agent_raw.get("system_prompt", "You are a helpful worker agent."),
126
+ )
127
+
128
+ # --- Model ---
129
+ model_raw = raw.get("model", {})
130
+ model = ModelConfig(
131
+ provider=model_raw.get("provider", "ollama"),
132
+ model_name=model_raw.get("model_name", "llama3.2"),
133
+ temperature=float(model_raw.get("temperature", 0.0)),
134
+ base_url=model_raw.get("base_url", "http://localhost:11434"),
135
+ api_key=model_raw.get("api_key", os.getenv("API_KEY")),
136
+ )
137
+
138
+ # --- MCP Clients ---
139
+ mcp_clients = []
140
+ for entry in raw.get("mcp_clients", []) or []:
141
+ mcp_clients.append(
142
+ MCPClientConfig(
143
+ name=entry["name"],
144
+ command=entry["command"],
145
+ args=entry.get("args", []),
146
+ env=entry.get("env", {}),
147
+ )
148
+ )
149
+
150
+ # --- Server ---
151
+ server_raw = raw.get("server", {})
152
+ server = ServerConfig(
153
+ name=server_raw.get("name", "worker-agent-server"),
154
+ port=int(server_raw.get("port", 8001)),
155
+ transport=server_raw.get("transport", "stdio"),
156
+ host=server_raw.get("host", "0.0.0.0"),
157
+ )
158
+
159
+ return AppConfig(agent=agent, model=model, mcp_clients=mcp_clients, server=server)