plato-sdk-v2 2.3.3__py3-none-any.whl → 2.4.2__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,16 +1,23 @@
1
- """Agent runner - run agents in Docker containers."""
1
+ """Agent runner - run agents in Docker containers.
2
+
3
+ Agents emit their own OTel spans for trajectory events. This runner:
4
+ 1. Runs agents in Docker containers
5
+ 2. Streams stdout/stderr for logging
6
+ 3. Passes OTel environment variables for trace context propagation
7
+ 4. Uploads artifacts to S3 when complete
8
+ """
2
9
 
3
10
  from __future__ import annotations
4
11
 
5
12
  import asyncio
13
+ import base64
6
14
  import json
7
15
  import logging
8
16
  import os
9
17
  import platform
10
- import tempfile
11
- from pathlib import Path
18
+ import uuid
12
19
 
13
- from plato.agents.logging import log_event, span, upload_artifacts
20
+ from opentelemetry import trace
14
21
 
15
22
  logger = logging.getLogger(__name__)
16
23
 
@@ -20,10 +27,10 @@ async def run_agent(
20
27
  config: dict,
21
28
  secrets: dict[str, str],
22
29
  instruction: str,
23
- workspace: str,
30
+ workspace: str | None = None,
24
31
  logs_dir: str | None = None,
25
32
  pull: bool = True,
26
- ) -> None:
33
+ ) -> str:
27
34
  """Run an agent in a Docker container.
28
35
 
29
36
  Args:
@@ -31,161 +38,228 @@ async def run_agent(
31
38
  config: Agent configuration dict
32
39
  secrets: Secret values (API keys, etc.)
33
40
  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)
41
+ workspace: Docker volume name for workspace (created if None)
42
+ logs_dir: Ignored (kept for backwards compatibility)
36
43
  pull: Whether to pull the image first
44
+
45
+ Returns:
46
+ The container name that was created (for cleanup purposes)
47
+
48
+ Note: Agents handle their own OTel tracing. This runner only passes
49
+ the trace context (TRACEPARENT) so agent spans link to the parent step.
50
+
51
+ Note: This uses Docker volumes (not bind mounts) for DIND compatibility.
52
+ The workspace parameter should be a Docker volume name.
37
53
  """
38
- logs_dir = logs_dir or tempfile.mkdtemp(prefix="agent_logs_")
39
- agent_name = image.split("/")[-1].split(":")[0]
40
-
41
- async with span(agent_name, span_type="agent", source="agent") as agent_span:
42
- agent_span.log(f"Starting agent: {agent_name} ({image})")
43
-
44
- # Pull image if requested
45
- if pull:
46
- agent_span.log(f"Pulling image: {image}")
47
- pull_proc = await asyncio.create_subprocess_exec(
48
- "docker",
49
- "pull",
50
- image,
51
- stdout=asyncio.subprocess.PIPE,
52
- stderr=asyncio.subprocess.STDOUT,
53
- )
54
- await pull_proc.wait()
55
-
56
- # Setup
57
- os.makedirs(os.path.join(logs_dir, "agent"), exist_ok=True)
58
- config_file = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False)
59
- json.dump(config, config_file)
60
- config_file.close()
61
-
62
- try:
63
- # Build docker command
64
- docker_cmd = ["docker", "run", "--rm"]
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
-
88
- if use_host_network:
89
- docker_cmd.extend(["--network=host", "--add-host=localhost:127.0.0.1"])
54
+ # Get session info from environment variables
55
+ session_id = os.environ.get("SESSION_ID")
56
+ otel_url = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
57
+ upload_url = os.environ.get("UPLOAD_URL")
58
+
59
+ # Pull image if requested
60
+ if pull:
61
+ pull_proc = await asyncio.create_subprocess_exec(
62
+ "docker",
63
+ "pull",
64
+ image,
65
+ stdout=asyncio.subprocess.PIPE,
66
+ stderr=asyncio.subprocess.STDOUT,
67
+ )
68
+ await pull_proc.wait()
69
+
70
+ # Encode config as base64 to pass via environment variable
71
+ # This avoids file mount issues in Docker-in-Docker scenarios
72
+ config_json = json.dumps(config)
73
+ config_b64 = base64.b64encode(config_json.encode()).decode()
74
+
75
+ # Generate a unique container name for inspection
76
+ container_name = f"agent-{uuid.uuid4().hex[:8]}"
77
+
78
+ # Use WORKSPACE_VOLUME env var if set (for DIND compatibility)
79
+ # Otherwise create a new volume
80
+ workspace_volume = os.environ.get("WORKSPACE_VOLUME") or workspace or f"workspace-{uuid.uuid4().hex[:8]}"
81
+ if not os.environ.get("WORKSPACE_VOLUME") and not workspace:
82
+ await asyncio.create_subprocess_exec(
83
+ "docker",
84
+ "volume",
85
+ "create",
86
+ workspace_volume,
87
+ stdout=asyncio.subprocess.DEVNULL,
88
+ stderr=asyncio.subprocess.DEVNULL,
89
+ )
90
+
91
+ # Create logs volume
92
+ logs_volume = f"logs-{uuid.uuid4().hex[:8]}"
93
+ await asyncio.create_subprocess_exec(
94
+ "docker",
95
+ "volume",
96
+ "create",
97
+ logs_volume,
98
+ stdout=asyncio.subprocess.DEVNULL,
99
+ stderr=asyncio.subprocess.DEVNULL,
100
+ )
101
+
102
+ try:
103
+ # Build docker command
104
+ docker_cmd = ["docker", "run", "--rm", "--privileged", "--name", container_name]
105
+
106
+ # Determine if we need host networking
107
+ use_host_network = False
108
+ is_macos = platform.system() == "Darwin"
109
+
110
+ if not is_macos:
111
+ try:
112
+ proc = await asyncio.create_subprocess_exec(
113
+ "iptables",
114
+ "-L",
115
+ "-n",
116
+ stdout=asyncio.subprocess.DEVNULL,
117
+ stderr=asyncio.subprocess.DEVNULL,
118
+ )
119
+ await proc.wait()
120
+ has_iptables = proc.returncode == 0
121
+ except (FileNotFoundError, PermissionError):
122
+ has_iptables = False
90
123
 
124
+ use_host_network = not has_iptables
125
+
126
+ if use_host_network:
127
+ docker_cmd.extend(["--network=host", "--add-host=localhost:127.0.0.1"])
128
+
129
+ # Use Docker volumes instead of bind mounts for DIND compatibility
130
+ docker_cmd.extend(
131
+ [
132
+ "-v",
133
+ f"{workspace_volume}:/workspace",
134
+ "-v",
135
+ f"{logs_volume}:/logs",
136
+ "-v",
137
+ "/var/run/docker.sock:/var/run/docker.sock",
138
+ "-w",
139
+ "/workspace",
140
+ "-e",
141
+ f"AGENT_CONFIG_B64={config_b64}",
142
+ ]
143
+ )
144
+
145
+ # Pass session info to agent
146
+ if otel_url:
147
+ traces_endpoint = f"{otel_url.rstrip('/')}/v1/traces"
148
+ docker_cmd.extend(["-e", f"OTEL_EXPORTER_OTLP_ENDPOINT={otel_url}"])
149
+ docker_cmd.extend(["-e", f"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT={traces_endpoint}"])
150
+ docker_cmd.extend(["-e", "OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf"])
151
+ if session_id:
152
+ docker_cmd.extend(["-e", f"SESSION_ID={session_id}"])
153
+ if upload_url:
154
+ docker_cmd.extend(["-e", f"UPLOAD_URL={upload_url}"])
155
+
156
+ # Pass trace context to agent for parent linking
157
+ # Agent spans will be children of the current step span
158
+ current_span = trace.get_current_span()
159
+ span_context = current_span.get_span_context()
160
+ if span_context.is_valid:
161
+ trace_id = format(span_context.trace_id, "032x")
162
+ span_id = format(span_context.span_id, "016x")
163
+ # W3C Trace Context format for TRACEPARENT
164
+ traceparent = f"00-{trace_id}-{span_id}-01"
91
165
  docker_cmd.extend(
92
166
  [
93
- "-v",
94
- f"{workspace}:/workspace",
95
- "-v",
96
- f"{logs_dir}:/logs",
97
- "-v",
98
- f"{config_file.name}:/config.json:ro",
99
- "-v",
100
- "/var/run/docker.sock:/var/run/docker.sock",
101
- "-w",
102
- "/workspace",
167
+ "-e",
168
+ f"TRACEPARENT={traceparent}",
169
+ "-e",
170
+ f"OTEL_TRACE_ID={trace_id}",
171
+ "-e",
172
+ f"OTEL_PARENT_SPAN_ID={span_id}",
103
173
  ]
104
174
  )
105
175
 
106
- for key, value in secrets.items():
107
- docker_cmd.extend(["-e", f"{key.upper()}={value}"])
176
+ for key, value in secrets.items():
177
+ docker_cmd.extend(["-e", f"{key.upper()}={value}"])
108
178
 
109
- docker_cmd.append(image)
179
+ docker_cmd.append(image)
110
180
 
111
- # Pass instruction via CLI arg (agents expect --instruction flag)
112
- docker_cmd.extend(["--instruction", instruction])
181
+ # Pass instruction via CLI arg
182
+ docker_cmd.extend(["--instruction", instruction])
113
183
 
114
- # Run container and stream output
115
- process = await asyncio.create_subprocess_exec(
116
- *docker_cmd,
117
- stdout=asyncio.subprocess.PIPE,
118
- stderr=asyncio.subprocess.STDOUT,
119
- )
184
+ logger.info(f"Starting container: {container_name}")
120
185
 
121
- # Stream output line by line, collecting for error reporting
122
- output_lines: list[str] = []
123
- assert process.stdout is not None
124
- while True:
125
- line = await process.stdout.readline()
126
- if not line:
127
- break
128
- decoded_line = line.decode().rstrip()
129
- output_lines.append(decoded_line)
130
- logger.info(f"[agent] {decoded_line}")
131
-
132
- await process.wait()
133
-
134
- if process.returncode != 0:
135
- # Get last N lines of output for error context
136
- error_context = "\n".join(output_lines[-50:]) if output_lines else "No output captured"
137
-
138
- # Log error event with container output
139
- await log_event(
140
- span_type="error",
141
- content=f"Agent failed with exit code {process.returncode}",
142
- source="agent",
143
- extra={
144
- "exit_code": process.returncode,
145
- "image": image,
146
- "agent_name": agent_name,
147
- "output": error_context,
148
- "output_line_count": len(output_lines),
149
- },
150
- )
186
+ # Run container - agents emit their own OTel spans
187
+ # Use large limit to handle agents that output long lines (e.g., JSON with file contents)
188
+ process = await asyncio.create_subprocess_exec(
189
+ *docker_cmd,
190
+ stdout=asyncio.subprocess.PIPE,
191
+ stderr=asyncio.subprocess.STDOUT,
192
+ limit=100 * 1024 * 1024, # 100MB buffer limit
193
+ )
151
194
 
152
- agent_span.set_extra(
153
- {
154
- "error": True,
155
- "exit_code": process.returncode,
156
- "output": error_context,
157
- }
195
+ # Get and print container IP in background
196
+ async def print_container_ip():
197
+ await asyncio.sleep(3) # Wait for container to start
198
+ try:
199
+ inspect_proc = await asyncio.create_subprocess_exec(
200
+ "docker",
201
+ "inspect",
202
+ "-f",
203
+ "{{.NetworkSettings.IPAddress}}",
204
+ container_name,
205
+ stdout=asyncio.subprocess.PIPE,
206
+ stderr=asyncio.subprocess.PIPE,
158
207
  )
208
+ stdout, _ = await inspect_proc.communicate()
209
+ container_ip = stdout.decode().strip()
210
+ if container_ip:
211
+ logger.info("=" * 50)
212
+ logger.info(f"Container: {container_name}")
213
+ logger.info(f"Container IP: {container_ip}")
214
+ logger.info(f"noVNC: http://{container_ip}:6080")
215
+ logger.info("=" * 50)
216
+ except Exception:
217
+ pass
218
+
219
+ asyncio.create_task(print_container_ip())
220
+
221
+ # Stream and capture output for error reporting using chunked reads to handle large lines
222
+ output_lines: list[str] = []
223
+ assert process.stdout is not None
224
+ buffer = ""
225
+ while True:
226
+ try:
227
+ chunk = await process.stdout.read(65536)
228
+ except Exception:
229
+ break
230
+ if not chunk:
231
+ break
232
+ buffer += chunk.decode(errors="replace")
233
+
234
+ while "\n" in buffer:
235
+ line, buffer = buffer.split("\n", 1)
236
+ output_lines.append(line)
237
+ # Print agent output in real-time
238
+ print(f"[agent] {line}")
239
+
240
+ # Handle any remaining content in buffer
241
+ if buffer.strip():
242
+ output_lines.append(buffer)
243
+ print(f"[agent] {buffer}")
244
+
245
+ await process.wait()
246
+
247
+ exit_code = process.returncode or 0
248
+ if exit_code != 0:
249
+ error_context = "\n".join(output_lines[-50:]) if output_lines else "No output captured"
250
+ raise RuntimeError(f"Agent failed with exit code {exit_code}\n\nAgent output:\n{error_context}")
251
+
252
+ finally:
253
+ # Clean up volumes
254
+ await asyncio.create_subprocess_exec(
255
+ "docker",
256
+ "volume",
257
+ "rm",
258
+ "-f",
259
+ logs_volume,
260
+ stdout=asyncio.subprocess.DEVNULL,
261
+ stderr=asyncio.subprocess.DEVNULL,
262
+ )
263
+ # Note: workspace_volume is not cleaned up as it may be shared
159
264
 
160
- raise RuntimeError(f"Agent failed with exit code {process.returncode}")
161
-
162
- agent_span.log("Agent completed successfully")
163
-
164
- finally:
165
- os.unlink(config_file.name)
166
-
167
- # Load trajectory and add to span
168
- trajectory_path = Path(logs_dir) / "agent" / "trajectory.json"
169
- if trajectory_path.exists():
170
- try:
171
- with open(trajectory_path) as f:
172
- trajectory = json.load(f)
173
- if isinstance(trajectory, dict) and "schema_version" in trajectory:
174
- # Add agent image to trajectory
175
- agent_data = trajectory.get("agent", {})
176
- extra = agent_data.get("extra") or {}
177
- extra["image"] = image
178
- agent_data["extra"] = extra
179
- trajectory["agent"] = agent_data
180
-
181
- # Log trajectory as separate event
182
- await log_event(
183
- span_type="trajectory",
184
- log_type="atif",
185
- extra=trajectory,
186
- source="agent",
187
- )
188
- except Exception as e:
189
- logger.warning(f"Failed to load trajectory: {e}")
190
-
191
- await upload_artifacts(logs_dir)
265
+ return container_name
@@ -10,10 +10,18 @@ from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
10
10
 
11
11
 
12
12
  class AgentConfig(BaseModel):
13
+ """Agent config - supports multiple formats.
14
+
15
+ New format: agent + version (version optional, defaults to latest)
16
+ Legacy format: agent_id (public_id)
17
+ """
18
+
13
19
  model_config = ConfigDict(
14
20
  extra="allow",
15
21
  )
16
- agent_id: Annotated[str, Field(title="Agent Id")]
22
+ agent: Annotated[str | None, Field(title="Agent")] = None
23
+ version: Annotated[str | None, Field(title="Version")] = None
24
+ agent_id: Annotated[str | None, Field(title="Agent Id")] = None # backwards compat
17
25
  config: Annotated[dict[str, Any] | None, Field(title="Config")] = {}
18
26
 
19
27
 
plato/v1/cli/agent.py CHANGED
@@ -757,8 +757,8 @@ def agent_schema(
757
757
  console.print(json.dumps(schema, indent=2))
758
758
 
759
759
 
760
- @agent_app.command(name="push")
761
- def agent_push(
760
+ @agent_app.command(name="publish")
761
+ def agent_publish(
762
762
  target: str = typer.Argument(".", help="Path to agent directory OR Harbor agent name"),
763
763
  all_agents: bool = typer.Option(False, "--all", "-a", help="Publish all agents in directory"),
764
764
  dry_run: bool = typer.Option(False, "--dry-run", help="Build without pushing to ECR"),
@@ -776,10 +776,10 @@ def agent_push(
776
776
  - Harbor agents: from installed Harbor package version
777
777
 
778
778
  Examples:
779
- plato agent push ./agents/my-agent # Custom agent
780
- plato agent push claude-code # Harbor built-in
781
- plato agent push --all ./agents # All agents in directory
782
- plato agent push claude-code --dry-run # Dry run
779
+ plato agent publish ./agents/my-agent # Custom agent
780
+ plato agent publish claude-code # Harbor built-in
781
+ plato agent publish --all ./agents # All agents in directory
782
+ plato agent publish claude-code --dry-run # Dry run
783
783
  """
784
784
 
785
785
  # Handle --all flag with directory
@@ -964,7 +964,7 @@ def agent_images():
964
964
  agents = data.get("agents", [])
965
965
  if not agents:
966
966
  console.print("[yellow]No published agents found[/yellow]")
967
- console.print("\n[dim]Publish with: plato agent push <path-or-name>[/dim]")
967
+ console.print("\n[dim]Publish with: plato agent publish <path-or-name>[/dim]")
968
968
  return
969
969
 
970
970
  console.print("[bold]Published Agent Images:[/bold]\n")