plato-sdk-v2 2.1.11__py3-none-any.whl → 2.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.
plato/agents/runner.py CHANGED
@@ -1,252 +1,99 @@
1
- """Agent runner - discovers and runs Plato agents."""
1
+ """Agent runner - run agents in Docker containers."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import json
6
7
  import logging
8
+ import os
9
+ import platform
10
+ import tempfile
7
11
  from pathlib import Path
8
- from typing import Annotated
9
12
 
10
- import typer
11
-
12
- from plato.agents.callback import ChronosCallback
13
-
14
- app = typer.Typer(
15
- name="plato-agent-runner",
16
- help="Run Plato agents",
17
- no_args_is_help=True,
18
- )
13
+ from plato.agents.logging import log_event, span, upload_artifacts
19
14
 
20
15
  logger = logging.getLogger(__name__)
21
16
 
22
17
 
23
- def discover_agents() -> None:
24
- """Discover and load installed agent packages via entry points.
25
-
26
- Agent packages declare entry points in pyproject.toml:
27
- [project.entry-points."plato.agents"]
28
- openhands = "openhands_agent:OpenHandsAgent"
29
-
30
- This function loads all such entry points, triggering registration.
31
- """
32
- import importlib.metadata
33
-
34
- try:
35
- eps = importlib.metadata.entry_points(group="plato.agents")
36
- except TypeError:
37
- # Python < 3.10 compatibility
38
- eps = importlib.metadata.entry_points().get("plato.agents", [])
39
-
40
- for ep in eps:
41
- try:
42
- ep.load()
43
- logger.debug(f"Loaded agent: {ep.name}")
44
- except Exception as e:
45
- logger.warning(f"Failed to load agent '{ep.name}': {e}")
46
-
47
-
48
- @app.command()
49
- def run(
50
- agent: Annotated[str, typer.Option("--agent", "-a", help="Agent name to run")],
51
- instruction: Annotated[str, typer.Option("--instruction", "-i", help="Task instruction")],
52
- config: Annotated[Path, typer.Option("--config", "-c", help="Path to config JSON file")],
53
- verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose logging")] = False,
54
- ) -> None:
55
- """Run an agent with the given instruction."""
56
- # Setup logging
57
- log_level = logging.DEBUG if verbose else logging.INFO
58
- logging.basicConfig(
59
- level=log_level,
60
- format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
61
- )
62
-
63
- if not config.exists():
64
- typer.echo(f"Error: Config file not found: {config}", err=True)
65
- raise typer.Exit(1)
66
-
67
- # Discover agents
68
- discover_agents()
69
-
70
- from plato.agents.base import get_agent, get_registered_agents
71
-
72
- agent_cls = get_agent(agent)
73
- if agent_cls is None:
74
- available = list(get_registered_agents().keys())
75
- typer.echo(f"Error: Agent '{agent}' not found. Available: {available}", err=True)
76
- raise typer.Exit(1)
77
-
78
- # Load config using the agent's typed config class
79
- config_class = agent_cls.get_config_class()
80
- agent_config = config_class.from_file(config)
81
-
82
- try:
83
- agent_instance = agent_cls()
84
- agent_instance.config = agent_config
85
- asyncio.run(agent_instance.run(instruction))
86
- except Exception as e:
87
- logger.exception(f"Agent execution failed: {e}")
88
- raise typer.Exit(1)
89
-
90
-
91
- @app.command("list")
92
- def list_agents(
93
- verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose logging")] = False,
18
+ async def run_agent(
19
+ image: str,
20
+ config: dict,
21
+ secrets: dict[str, str],
22
+ instruction: str,
23
+ workspace: str,
24
+ logs_dir: str | None = None,
25
+ pull: bool = True,
94
26
  ) -> None:
95
- """List available agents."""
96
- if verbose:
97
- logging.basicConfig(level=logging.DEBUG)
98
-
99
- discover_agents()
100
-
101
- from plato.agents.base import get_registered_agents
102
-
103
- agents = get_registered_agents()
104
- if not agents:
105
- typer.echo("No agents found.")
106
- return
107
-
108
- typer.echo("Available agents:")
109
- for name, cls in agents.items():
110
- desc = getattr(cls, "description", "") or ""
111
- version = cls.get_version()
112
- typer.echo(f" {name} (v{version}): {desc}")
113
-
114
-
115
- def main() -> None:
116
- """CLI entry point."""
117
- app()
118
-
119
-
120
- # =============================================================================
121
- # AgentRunner - Docker-based execution (existing functionality)
122
- # =============================================================================
123
-
124
-
125
- class AgentRunResult:
126
- """Result of running an agent in Docker.
127
-
128
- This class is an async iterator that yields output lines from the agent.
129
- It also provides access to the logs directory where agent logs are stored.
27
+ """Run an agent in a Docker container.
28
+
29
+ Args:
30
+ image: Docker image URI
31
+ config: Agent configuration dict
32
+ secrets: Secret values (API keys, etc.)
33
+ instruction: Task instruction for the agent
34
+ workspace: Host directory to mount as /workspace
35
+ logs_dir: Host directory for logs (temp dir if None)
36
+ pull: Whether to pull the image first
130
37
  """
38
+ logs_dir = logs_dir or tempfile.mkdtemp(prefix="agent_logs_")
39
+ agent_name = image.split("/")[-1].split(":")[0]
131
40
 
132
- def __init__(
133
- self,
134
- image: str,
135
- config: dict,
136
- secrets: dict[str, str],
137
- instruction: str,
138
- workspace: str,
139
- logs_dir: str | None,
140
- pull: bool,
141
- callback_url: str,
142
- session_id: str,
143
- ):
144
- self._image = image
145
- self._config = config
146
- self._secrets = secrets
147
- self._instruction = instruction
148
- self._workspace = workspace
149
- self._pull = pull
150
-
151
- self._callback = ChronosCallback(
152
- callback_url=callback_url,
153
- session_id=session_id,
154
- )
155
-
156
- if logs_dir is None:
157
- import tempfile
158
-
159
- self._logs_dir = tempfile.mkdtemp(prefix="agent_logs_")
160
- else:
161
- self._logs_dir = logs_dir
162
-
163
- @property
164
- def logs_dir(self) -> str:
165
- """Host path where agent logs are stored."""
166
- return self._logs_dir
41
+ async with span(agent_name, span_type="agent", source="agent") as agent_span:
42
+ agent_span.log(f"Starting agent: {agent_name} ({image})")
167
43
 
168
- @property
169
- def callback(self) -> ChronosCallback:
170
- """The Chronos callback client."""
171
- return self._callback
172
-
173
- def __aiter__(self):
174
- return self._stream()
175
-
176
- async def _check_iptables_support(self) -> bool:
177
- """Check if iptables is available."""
178
- try:
179
- proc = await asyncio.create_subprocess_exec(
180
- "iptables",
181
- "-L",
182
- "-n",
183
- stdout=asyncio.subprocess.DEVNULL,
184
- stderr=asyncio.subprocess.DEVNULL,
185
- )
186
- await proc.wait()
187
- return proc.returncode == 0
188
- except (FileNotFoundError, PermissionError):
189
- return False
190
-
191
- async def _stream(self):
192
- """Stream output from the agent."""
193
- import json
194
- import os
195
- import tempfile
196
-
197
- log_buffer: list[dict] = []
198
-
199
- async def flush_logs():
200
- if not log_buffer or not self._callback.enabled:
201
- return
202
- await self._callback.push_logs(log_buffer.copy())
203
- log_buffer.clear()
204
-
205
- agent_name = self._image.split("/")[-1].split(":")[0]
206
- await self._callback.push_log(f"Starting agent: {agent_name} ({self._image})")
207
-
208
- if self._pull:
44
+ # Pull image if requested
45
+ if pull:
46
+ agent_span.log(f"Pulling image: {image}")
209
47
  pull_proc = await asyncio.create_subprocess_exec(
210
48
  "docker",
211
49
  "pull",
212
- self._image,
50
+ image,
213
51
  stdout=asyncio.subprocess.PIPE,
214
52
  stderr=asyncio.subprocess.STDOUT,
215
53
  )
216
- assert pull_proc.stdout is not None
217
- while True:
218
- line = await pull_proc.stdout.readline()
219
- if not line:
220
- break
221
- yield f"[pull] {line.decode().rstrip()}"
222
54
  await pull_proc.wait()
223
55
 
224
- agent_logs_subdir = os.path.join(self._logs_dir, "agent")
225
- os.makedirs(agent_logs_subdir, exist_ok=True)
226
-
56
+ # Setup
57
+ os.makedirs(os.path.join(logs_dir, "agent"), exist_ok=True)
227
58
  config_file = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False)
228
- json.dump(self._config, config_file)
59
+ json.dump(config, config_file)
229
60
  config_file.close()
230
61
 
231
- agent_failed = False
232
- error_message = ""
233
-
234
62
  try:
235
- use_host_network = not await self._check_iptables_support()
236
- if use_host_network:
237
- yield "[info] iptables not available, using host network mode"
238
-
63
+ # Build docker command
239
64
  docker_cmd = ["docker", "run", "--rm"]
240
65
 
66
+ # Determine if we need host networking:
67
+ # - Required on Linux without iptables for connectivity
68
+ # - Skip on macOS where --network=host doesn't work properly
69
+ use_host_network = False
70
+ is_macos = platform.system() == "Darwin"
71
+
72
+ if not is_macos:
73
+ try:
74
+ proc = await asyncio.create_subprocess_exec(
75
+ "iptables",
76
+ "-L",
77
+ "-n",
78
+ stdout=asyncio.subprocess.DEVNULL,
79
+ stderr=asyncio.subprocess.DEVNULL,
80
+ )
81
+ await proc.wait()
82
+ has_iptables = proc.returncode == 0
83
+ except (FileNotFoundError, PermissionError):
84
+ has_iptables = False
85
+
86
+ use_host_network = not has_iptables
87
+
241
88
  if use_host_network:
242
89
  docker_cmd.extend(["--network=host", "--add-host=localhost:127.0.0.1"])
243
90
 
244
91
  docker_cmd.extend(
245
92
  [
246
93
  "-v",
247
- f"{self._workspace}:/workspace",
94
+ f"{workspace}:/workspace",
248
95
  "-v",
249
- f"{self._logs_dir}:/logs",
96
+ f"{logs_dir}:/logs",
250
97
  "-v",
251
98
  f"{config_file.name}:/config.json:ro",
252
99
  "-w",
@@ -254,112 +101,61 @@ class AgentRunResult:
254
101
  ]
255
102
  )
256
103
 
257
- for key, value in self._secrets.items():
104
+ for key, value in secrets.items():
258
105
  docker_cmd.extend(["-e", f"{key.upper()}={value}"])
259
106
 
260
- docker_cmd.append(self._image)
261
- docker_cmd.extend(["--instruction", self._instruction])
107
+ docker_cmd.append(image)
262
108
 
109
+ # Pass instruction via CLI arg (agents expect --instruction flag)
110
+ docker_cmd.extend(["--instruction", instruction])
111
+
112
+ # Run container and stream output
263
113
  process = await asyncio.create_subprocess_exec(
264
114
  *docker_cmd,
265
115
  stdout=asyncio.subprocess.PIPE,
266
116
  stderr=asyncio.subprocess.STDOUT,
267
117
  )
268
- assert process.stdout is not None
269
118
 
119
+ # Stream output line by line
120
+ assert process.stdout is not None
270
121
  while True:
271
122
  line = await process.stdout.readline()
272
123
  if not line:
273
124
  break
274
- decoded = line.decode().rstrip()
275
- yield decoded
276
-
277
- if self._callback.enabled:
278
- log_buffer.append({"level": "info", "message": decoded})
279
- if len(log_buffer) >= 10:
280
- await flush_logs()
125
+ logger.info(f"[agent] {line.decode().rstrip()}")
281
126
 
282
127
  await process.wait()
283
128
 
284
129
  if process.returncode != 0:
285
- agent_failed = True
286
- error_message = f"Agent failed with exit code {process.returncode}"
287
- except Exception as e:
288
- agent_failed = True
289
- error_message = str(e)
290
- raise
291
- finally:
292
- os.unlink(config_file.name)
293
- await flush_logs()
294
-
295
- if self._callback.enabled:
296
- if agent_failed:
297
- await self._callback.push_log(error_message, level="error")
298
- else:
299
- await self._callback.push_log("Agent completed successfully")
300
- await self._callback.upload_artifacts(logs_dir=self._logs_dir)
301
-
302
- if agent_failed:
303
- raise RuntimeError(error_message)
304
-
130
+ raise RuntimeError(f"Agent failed with exit code {process.returncode}")
305
131
 
306
- class AgentRunner:
307
- """Run agents in Docker containers.
308
-
309
- This provides isolated execution of agents. For direct execution,
310
- use the plato-agent-runner CLI or instantiate the agent directly.
311
-
312
- Example:
313
- async for line in AgentRunner.run(
314
- image="us-docker.pkg.dev/plato-prod/agents/openhands:latest",
315
- config={"model_name": "anthropic/claude-sonnet-4"},
316
- secrets={"anthropic_api_key": "sk-..."},
317
- instruction="Fix the bug",
318
- workspace="/path/to/repo",
319
- ):
320
- print(line)
321
- """
322
-
323
- @staticmethod
324
- def run(
325
- image: str,
326
- config: dict,
327
- secrets: dict[str, str],
328
- instruction: str,
329
- workspace: str,
330
- logs_dir: str | None = None,
331
- pull: bool = True,
332
- callback_url: str = "",
333
- session_id: str = "",
334
- ) -> AgentRunResult:
335
- """Run an agent in a Docker container.
336
-
337
- Args:
338
- image: Docker image URI
339
- config: Agent configuration dict
340
- secrets: Secret values (API keys, etc.)
341
- instruction: Task instruction for the agent
342
- workspace: Host directory to mount as /workspace
343
- logs_dir: Host directory for logs (temp dir if None)
344
- pull: Whether to pull the image first
345
- callback_url: Chronos callback URL
346
- session_id: Chronos session ID
347
-
348
- Returns:
349
- AgentRunResult that can be async-iterated for output.
350
- """
351
- return AgentRunResult(
352
- image=image,
353
- config=config,
354
- secrets=secrets,
355
- instruction=instruction,
356
- workspace=workspace,
357
- logs_dir=logs_dir,
358
- pull=pull,
359
- callback_url=callback_url,
360
- session_id=session_id,
361
- )
132
+ agent_span.log("Agent completed successfully")
362
133
 
134
+ finally:
135
+ os.unlink(config_file.name)
363
136
 
364
- if __name__ == "__main__":
365
- main()
137
+ # Load trajectory and add to span
138
+ trajectory_path = Path(logs_dir) / "agent" / "trajectory.json"
139
+ if trajectory_path.exists():
140
+ try:
141
+ with open(trajectory_path) as f:
142
+ trajectory = json.load(f)
143
+ if isinstance(trajectory, dict) and "schema_version" in trajectory:
144
+ # Add agent image to trajectory
145
+ agent_data = trajectory.get("agent", {})
146
+ extra = agent_data.get("extra") or {}
147
+ extra["image"] = image
148
+ agent_data["extra"] = extra
149
+ trajectory["agent"] = agent_data
150
+
151
+ # Log trajectory as separate event
152
+ await log_event(
153
+ span_type="trajectory",
154
+ log_type="atif",
155
+ extra=trajectory,
156
+ source="agent",
157
+ )
158
+ except Exception as e:
159
+ logger.warning(f"Failed to load trajectory: {e}")
160
+
161
+ await upload_artifacts(logs_dir)
@@ -9,7 +9,7 @@ Spec: https://harborframework.com/docs/trajectory-format
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
- from datetime import datetime
12
+ from datetime import datetime, timezone
13
13
  from typing import Any, Literal
14
14
 
15
15
  from pydantic import BaseModel, Field
@@ -115,7 +115,7 @@ class Step(BaseModel):
115
115
  """Create a user step."""
116
116
  return cls(
117
117
  step_id=step_id,
118
- timestamp=datetime.utcnow().isoformat() + "Z",
118
+ timestamp=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
119
119
  source="user",
120
120
  message=message,
121
121
  **kwargs,
@@ -136,7 +136,7 @@ class Step(BaseModel):
136
136
  """Create an agent step."""
137
137
  return cls(
138
138
  step_id=step_id,
139
- timestamp=datetime.utcnow().isoformat() + "Z",
139
+ timestamp=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
140
140
  source="agent",
141
141
  message=message,
142
142
  model_name=model_name,
@@ -152,7 +152,7 @@ class Step(BaseModel):
152
152
  """Create a system step."""
153
153
  return cls(
154
154
  step_id=step_id,
155
- timestamp=datetime.utcnow().isoformat() + "Z",
155
+ timestamp=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
156
156
  source="system",
157
157
  message=message,
158
158
  **kwargs,
@@ -4,7 +4,7 @@
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
- from typing import Annotated, Any, Dict, List
7
+ from typing import Annotated, Any
8
8
 
9
9
  from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
10
10