plexspaces 0.1.4__tar.gz → 0.1.6__tar.gz

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 (107) hide show
  1. {plexspaces-0.1.4 → plexspaces-0.1.6}/PKG-INFO +1 -1
  2. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/__init__.py +6 -0
  3. plexspaces-0.1.6/plexspaces/agent.py +407 -0
  4. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/actor/v1/__init__.py +3 -3
  5. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/metrics/v1/__init__.py +1 -2
  6. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/node/v1/__init__.py +10 -88
  7. plexspaces-0.1.6/plexspaces/generated/plexspaces/security/v1/__init__.py +822 -0
  8. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/wasm/v1/__init__.py +1 -1
  9. plexspaces-0.1.6/plexspaces/generated/plexspaces/workflow/__init__.py +0 -0
  10. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/host.py +101 -56
  11. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/runtime.py +25 -3
  12. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces.egg-info/PKG-INFO +1 -1
  13. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces.egg-info/SOURCES.txt +3 -0
  14. {plexspaces-0.1.4 → plexspaces-0.1.6}/pyproject.toml +1 -1
  15. plexspaces-0.1.6/tests/test_agent.py +314 -0
  16. {plexspaces-0.1.4 → plexspaces-0.1.6}/tests/test_runtime.py +4 -3
  17. plexspaces-0.1.4/plexspaces/generated/plexspaces/security/v1/__init__.py +0 -254
  18. {plexspaces-0.1.4 → plexspaces-0.1.6}/README.md +0 -0
  19. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/actor_id.py +0 -0
  20. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/decorators.py +0 -0
  21. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/__init__.py +0 -0
  22. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/buf/__init__.py +0 -0
  23. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/buf/validate/__init__.py +0 -0
  24. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/google/__init__.py +0 -0
  25. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/google/api/__init__.py +0 -0
  26. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/grpc/__init__.py +0 -0
  27. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/grpc/gateway/__init__.py +0 -0
  28. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/grpc/gateway/protoc_gen_openapiv2/__init__.py +0 -0
  29. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/grpc/gateway/protoc_gen_openapiv2/options/__init__.py +0 -0
  30. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/__init__.py +0 -0
  31. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/actor/__init__.py +0 -0
  32. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/application/__init__.py +0 -0
  33. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/application/v1/__init__.py +0 -0
  34. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/behaviors/__init__.py +0 -0
  35. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/behaviors/v1/__init__.py +0 -0
  36. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/channel/__init__.py +0 -0
  37. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/channel/v1/__init__.py +0 -0
  38. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/circuitbreaker/__init__.py +0 -0
  39. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/circuitbreaker/prv/__init__.py +0 -0
  40. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/common/__init__.py +0 -0
  41. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/common/v1/__init__.py +0 -0
  42. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/config/__init__.py +0 -0
  43. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/config/v1/__init__.py +0 -0
  44. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/dashboard/__init__.py +0 -0
  45. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/dashboard/v1/__init__.py +0 -0
  46. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/facets/__init__.py +0 -0
  47. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/facets/v1/__init__.py +0 -0
  48. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/firecracker/__init__.py +0 -0
  49. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/firecracker/v1/__init__.py +0 -0
  50. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/grpc/__init__.py +0 -0
  51. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/grpc/v1/__init__.py +0 -0
  52. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/journaling/__init__.py +0 -0
  53. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/journaling/v1/__init__.py +0 -0
  54. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/keyvalue/__init__.py +0 -0
  55. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/keyvalue/v1/__init__.py +0 -0
  56. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/locks/__init__.py +0 -0
  57. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/locks/prv/__init__.py +0 -0
  58. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/mailbox/__init__.py +0 -0
  59. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/mailbox/v1/__init__.py +0 -0
  60. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/metrics/__init__.py +0 -0
  61. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/node/__init__.py +0 -0
  62. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/object_registry/__init__.py +0 -0
  63. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/object_registry/v1/__init__.py +0 -0
  64. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/persistence/__init__.py +0 -0
  65. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/persistence/prv/__init__.py +0 -0
  66. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/pool/__init__.py +0 -0
  67. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/pool/v1/__init__.py +0 -0
  68. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/processgroups/__init__.py +0 -0
  69. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/processgroups/v1/__init__.py +0 -0
  70. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/scheduling/__init__.py +0 -0
  71. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/scheduling/v1/__init__.py +0 -0
  72. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/security/__init__.py +0 -0
  73. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/services/__init__.py +0 -0
  74. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/services/prv/__init__.py +0 -0
  75. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/storage/__init__.py +0 -0
  76. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/storage/v1/__init__.py +0 -0
  77. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/supervision/__init__.py +0 -0
  78. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/supervision/v1/__init__.py +0 -0
  79. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/system/__init__.py +0 -0
  80. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/system/v1/__init__.py +0 -0
  81. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/timer/__init__.py +0 -0
  82. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/timer/v1/__init__.py +0 -0
  83. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/tuplespace/__init__.py +0 -0
  84. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/tuplespace/v1/__init__.py +0 -0
  85. {plexspaces-0.1.4/plexspaces/generated/plexspaces/wasm → plexspaces-0.1.6/plexspaces/generated/plexspaces/v1}/__init__.py +0 -0
  86. {plexspaces-0.1.4/plexspaces/generated/plexspaces/workflow → plexspaces-0.1.6/plexspaces/generated/plexspaces/wasm}/__init__.py +0 -0
  87. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/generated/plexspaces/workflow/v1/__init__.py +0 -0
  88. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/leader_worker.py +0 -0
  89. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/proto_wire.py +0 -0
  90. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces/workflow.py +0 -0
  91. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces.egg-info/dependency_links.txt +0 -0
  92. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces.egg-info/entry_points.txt +0 -0
  93. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces.egg-info/requires.txt +0 -0
  94. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces.egg-info/top_level.txt +0 -0
  95. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces_cli/__init__.py +0 -0
  96. {plexspaces-0.1.4 → plexspaces-0.1.6}/plexspaces_cli/build.py +0 -0
  97. {plexspaces-0.1.4 → plexspaces-0.1.6}/setup.cfg +0 -0
  98. {plexspaces-0.1.4 → plexspaces-0.1.6}/tests/test_abstractions_contract.py +0 -0
  99. {plexspaces-0.1.4 → plexspaces-0.1.6}/tests/test_actor_id.py +0 -0
  100. {plexspaces-0.1.4 → plexspaces-0.1.6}/tests/test_application_metrics_host.py +0 -0
  101. {plexspaces-0.1.4 → plexspaces-0.1.6}/tests/test_decorators.py +0 -0
  102. {plexspaces-0.1.4 → plexspaces-0.1.6}/tests/test_host.py +0 -0
  103. {plexspaces-0.1.4 → plexspaces-0.1.6}/tests/test_host_collective.py +0 -0
  104. {plexspaces-0.1.4 → plexspaces-0.1.6}/tests/test_host_tuple_space.py +0 -0
  105. {plexspaces-0.1.4 → plexspaces-0.1.6}/tests/test_http_fetch_wire.py +0 -0
  106. {plexspaces-0.1.4 → plexspaces-0.1.6}/tests/test_lock_wire.py +0 -0
  107. {plexspaces-0.1.4 → plexspaces-0.1.6}/tests/test_workflow_retry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plexspaces
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: PlexSpaces Python SDK - Build actors with minimal boilerplate
5
5
  Author: PlexSpaces Contributors
6
6
  License: AGPL-3.0-or-later
@@ -51,6 +51,7 @@ from .decorators import (
51
51
  signal_handler,
52
52
  query_handler,
53
53
  )
54
+ from .agent import AgentLoop, AgentConfig, AgentTrajectory, AgentStep, AgentStepKind
54
55
  from .host import host, pg_first, ServiceHttpClient
55
56
  from .workflow import default_retry_config, with_retry
56
57
  from .leader_worker import LeaderWorkerClient, list_worker_node_ids
@@ -59,6 +60,11 @@ from .actor_id import ActorID
59
60
  __all__ = [
60
61
  "ActorID",
61
62
  "actor",
63
+ "AgentConfig",
64
+ "AgentLoop",
65
+ "AgentStep",
66
+ "AgentStepKind",
67
+ "AgentTrajectory",
62
68
  "default_retry_config",
63
69
  "event_actor",
64
70
  "gen_server_actor",
@@ -0,0 +1,407 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2025 PlexSpaces Contributors
3
+ #
4
+ # This file is part of PlexSpaces.
5
+ #
6
+ # PlexSpaces is free software: you can redistribute it and/or modify it under
7
+ # the terms of the GNU Affero General Public License as published by the Free
8
+ # Software Foundation, either version 3 of the License, or (at your option)
9
+ # any later version.
10
+ #
11
+ # PlexSpaces is distributed in the hope that it will be useful, but WITHOUT
12
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
13
+ # FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
14
+ # details.
15
+ #
16
+ # You should have received a copy of the GNU Affero General Public License
17
+ # along with PlexSpaces. If not, see <https://www.gnu.org/licenses/>.
18
+
19
+ """
20
+ Agent harness utilities for PlexSpaces Python SDK.
21
+
22
+ ``AgentLoop`` is a **standalone class** (no decorator magic, no class injection)
23
+ that provides structured OODA step tracking, token budget enforcement, and
24
+ suspend/resume helpers. Use it with ``@workflow_actor`` for durable agent
25
+ execution.
26
+
27
+ Example::
28
+
29
+ from plexspaces import workflow_actor, state, run_handler, host
30
+ from plexspaces.agent import AgentLoop, AgentConfig
31
+
32
+ @workflow_actor
33
+ class ResearchAgent:
34
+ task: str = state(default="")
35
+
36
+ @run_handler
37
+ def run(self, task: str = "") -> dict:
38
+ loop = AgentLoop(
39
+ actor_id=host.actor_id(),
40
+ config=AgentConfig(max_iterations=10, token_budget=4096),
41
+ )
42
+ while not loop.iteration_limit_reached() and not loop.budget_exceeded():
43
+ obs = loop.observe({"task": task})
44
+ plan = loop.orient(obs)
45
+ action = loop.decide(plan)
46
+ result = loop.act(action)
47
+ loop.increment_iteration()
48
+ if result.get("done"):
49
+ break
50
+ return loop.finalize_trajectory("completed", "task done")
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import time
56
+ import uuid
57
+ from dataclasses import dataclass
58
+ from typing import Any, Dict, List, Optional
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Utilities
63
+ # ---------------------------------------------------------------------------
64
+
65
+ def _now_ms() -> int:
66
+ """Current time as integer milliseconds since the Unix epoch."""
67
+ return int(time.time() * 1000)
68
+
69
+
70
+ def _new_ulid() -> str:
71
+ """Generate a new ULID-compatible unique string (UUID4 fallback)."""
72
+ return str(uuid.uuid4())
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # AgentStepKind constants
77
+ # ---------------------------------------------------------------------------
78
+
79
+ class AgentStepKind:
80
+ OBSERVE = "observe"
81
+ ORIENT = "orient"
82
+ DECIDE = "decide"
83
+ ACT = "act"
84
+ TOOL_CALL = "tool_call"
85
+ SUSPEND = "suspend"
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # AgentStep
90
+ # ---------------------------------------------------------------------------
91
+
92
+ class AgentStep:
93
+ """A single step in an agent's OODA trajectory."""
94
+
95
+ def __init__(self, kind: str, method: str, input_data: Any = None):
96
+ self.step_id: str = _new_ulid()
97
+ self.kind: str = kind
98
+ self.method: str = method
99
+ self.input: Any = input_data
100
+ self.output: Any = None
101
+ self.started_at_ms: int = _now_ms()
102
+ self.completed_at_ms: Optional[int] = None
103
+ self.success: bool = False
104
+ self.error: Optional[str] = None
105
+ self.input_tokens: int = 0
106
+ self.output_tokens: int = 0
107
+ self.model: str = ""
108
+ self.metadata: Dict[str, str] = {}
109
+
110
+ def complete(
111
+ self,
112
+ output: Any,
113
+ input_tokens: int = 0,
114
+ output_tokens: int = 0,
115
+ model: str = "",
116
+ ) -> "AgentStep":
117
+ """Mark the step as successfully completed."""
118
+ self.output = output
119
+ self.completed_at_ms = _now_ms()
120
+ self.success = True
121
+ self.input_tokens = input_tokens
122
+ self.output_tokens = output_tokens
123
+ self.model = model
124
+ return self
125
+
126
+ def fail(self, error: str) -> "AgentStep":
127
+ """Mark the step as failed."""
128
+ self.completed_at_ms = _now_ms()
129
+ self.success = False
130
+ self.error = error
131
+ return self
132
+
133
+ @property
134
+ def duration_ms(self) -> int:
135
+ end = self.completed_at_ms or _now_ms()
136
+ return max(0, end - self.started_at_ms)
137
+
138
+ def to_dict(self) -> Dict[str, Any]:
139
+ return {
140
+ "step_id": self.step_id,
141
+ "kind": self.kind,
142
+ "method": self.method,
143
+ "input": self.input,
144
+ "output": self.output,
145
+ "started_at_ms": self.started_at_ms,
146
+ "completed_at_ms": self.completed_at_ms,
147
+ "duration_ms": self.duration_ms,
148
+ "success": self.success,
149
+ "error": self.error,
150
+ "input_tokens": self.input_tokens,
151
+ "output_tokens": self.output_tokens,
152
+ "model": self.model,
153
+ "metadata": self.metadata,
154
+ }
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # AgentTrajectory
159
+ # ---------------------------------------------------------------------------
160
+
161
+ class AgentTrajectory:
162
+ """Complete trajectory for one agent run (all OODA steps, token totals)."""
163
+
164
+ def __init__(
165
+ self,
166
+ agent_actor_id: str,
167
+ eval_run_id: str = "",
168
+ scenario_id: str = "",
169
+ ):
170
+ self.trajectory_id: str = _new_ulid()
171
+ self.agent_actor_id: str = agent_actor_id
172
+ self.eval_run_id: str = eval_run_id
173
+ self.scenario_id: str = scenario_id
174
+ self.steps: List[AgentStep] = []
175
+ self.outcome: str = ""
176
+ self.outcome_detail: str = ""
177
+ self.started_at_ms: int = _now_ms()
178
+ self.completed_at_ms: Optional[int] = None
179
+ self.total_input_tokens: int = 0
180
+ self.total_output_tokens: int = 0
181
+
182
+ @property
183
+ def duration_ms(self) -> int:
184
+ end = self.completed_at_ms or _now_ms()
185
+ return max(0, end - self.started_at_ms)
186
+
187
+ @property
188
+ def step_count(self) -> int:
189
+ return len(self.steps)
190
+
191
+ def add_step(self, step: AgentStep) -> None:
192
+ """Append a completed step and accumulate token totals."""
193
+ self.steps.append(step)
194
+ self.total_input_tokens += step.input_tokens
195
+ self.total_output_tokens += step.output_tokens
196
+
197
+ def complete(self, outcome: str, detail: str = "") -> None:
198
+ """Finalise the trajectory with an outcome string."""
199
+ self.outcome = outcome
200
+ self.outcome_detail = detail
201
+ self.completed_at_ms = _now_ms()
202
+
203
+ def to_dict(self) -> Dict[str, Any]:
204
+ return {
205
+ "trajectory_id": self.trajectory_id,
206
+ "agent_actor_id": self.agent_actor_id,
207
+ "eval_run_id": self.eval_run_id,
208
+ "scenario_id": self.scenario_id,
209
+ "steps": [s.to_dict() for s in self.steps],
210
+ "step_count": len(self.steps),
211
+ "outcome": self.outcome,
212
+ "outcome_detail": self.outcome_detail,
213
+ "started_at_ms": self.started_at_ms,
214
+ "completed_at_ms": self.completed_at_ms,
215
+ "duration_ms": self.duration_ms,
216
+ "total_input_tokens": self.total_input_tokens,
217
+ "total_output_tokens": self.total_output_tokens,
218
+ }
219
+
220
+
221
+ # ---------------------------------------------------------------------------
222
+ # AgentConfig
223
+ # ---------------------------------------------------------------------------
224
+
225
+ @dataclass
226
+ class AgentConfig:
227
+ """Configuration for an AgentLoop."""
228
+ max_iterations: int = 10
229
+ token_budget: int = 0 # 0 = no budget limit
230
+
231
+
232
+ # ---------------------------------------------------------------------------
233
+ # AgentLoop
234
+ # ---------------------------------------------------------------------------
235
+
236
+ class AgentLoop:
237
+ """
238
+ Standalone agent harness utility.
239
+
240
+ Records OODA steps, enforces token budget and iteration limits, and
241
+ produces an ``AgentTrajectory`` on finalisation.
242
+
243
+ This is a **plain class** — there is no class injection or metaclass magic.
244
+ Use it inside a ``@workflow_actor`` ``run_handler``.
245
+
246
+ :param actor_id: ID of the hosting actor.
247
+ :param config: Optional ``AgentConfig`` (max_iterations, token_budget).
248
+ :param eval_run_id: Optional eval run ID to tag the trajectory.
249
+ :param scenario_id: Optional scenario ID to tag the trajectory.
250
+ """
251
+
252
+ def __init__(
253
+ self,
254
+ actor_id: str,
255
+ config: Optional[AgentConfig] = None,
256
+ eval_run_id: str = "",
257
+ scenario_id: str = "",
258
+ ):
259
+ self._actor_id = actor_id
260
+ self._config = config or AgentConfig()
261
+ self._trajectory = AgentTrajectory(
262
+ agent_actor_id=actor_id,
263
+ eval_run_id=eval_run_id,
264
+ scenario_id=scenario_id,
265
+ )
266
+ self._iteration_count: int = 0
267
+ self.is_suspended: bool = False
268
+
269
+ # ── Private helpers ──────────────────────────────────────────────────────
270
+
271
+ def _record(
272
+ self,
273
+ kind: str,
274
+ input_data: Any,
275
+ output: Any = None,
276
+ input_tokens: int = 0,
277
+ output_tokens: int = 0,
278
+ model: str = "",
279
+ error: Optional[str] = None,
280
+ ) -> AgentStep:
281
+ started = _now_ms()
282
+ step = AgentStep(kind=kind, method=kind, input_data=input_data)
283
+ step.started_at_ms = started
284
+ if error:
285
+ step.fail(error)
286
+ else:
287
+ step.complete(
288
+ output=output,
289
+ input_tokens=input_tokens,
290
+ output_tokens=output_tokens,
291
+ model=model,
292
+ )
293
+ self._trajectory.add_step(step)
294
+ return step
295
+
296
+ # ── OODA step methods ─────────────────────────────────────────────────────
297
+
298
+ def observe(self, input_data: Any, **kwargs: Any) -> Any:
299
+ """Record an OBSERVE step. Returns *input_data* unchanged."""
300
+ self._record(AgentStepKind.OBSERVE, input_data, output=input_data, **kwargs)
301
+ return input_data
302
+
303
+ def orient(self, input_data: Any, **kwargs: Any) -> Any:
304
+ """Record an ORIENT step. Returns *input_data* unchanged."""
305
+ self._record(AgentStepKind.ORIENT, input_data, output=input_data, **kwargs)
306
+ return input_data
307
+
308
+ def decide(self, input_data: Any, **kwargs: Any) -> Any:
309
+ """Record a DECIDE step. Returns *input_data* unchanged."""
310
+ self._record(AgentStepKind.DECIDE, input_data, output=input_data, **kwargs)
311
+ return input_data
312
+
313
+ def act(
314
+ self,
315
+ input_data: Any,
316
+ output: Any = None,
317
+ input_tokens: int = 0,
318
+ output_tokens: int = 0,
319
+ model: str = "",
320
+ **kwargs: Any,
321
+ ) -> Any:
322
+ """Record an ACT step. Returns *output* (or *input_data* if output is None)."""
323
+ result = output if output is not None else input_data
324
+ self._record(
325
+ AgentStepKind.ACT,
326
+ input_data,
327
+ output=result,
328
+ input_tokens=input_tokens,
329
+ output_tokens=output_tokens,
330
+ model=model,
331
+ )
332
+ return result
333
+
334
+ def tool_call(
335
+ self,
336
+ name: str,
337
+ args: Any,
338
+ result: Any,
339
+ started_at_ms: Optional[int] = None,
340
+ input_tokens: int = 0,
341
+ output_tokens: int = 0,
342
+ model: str = "",
343
+ ) -> Any:
344
+ """Record a TOOL_CALL step. Returns *result*."""
345
+ step = AgentStep(
346
+ kind=AgentStepKind.TOOL_CALL,
347
+ method=name,
348
+ input_data=args,
349
+ )
350
+ if started_at_ms is not None:
351
+ step.started_at_ms = started_at_ms
352
+ step.complete(
353
+ output=result,
354
+ input_tokens=input_tokens,
355
+ output_tokens=output_tokens,
356
+ model=model,
357
+ )
358
+ self._trajectory.add_step(step)
359
+ return result
360
+
361
+ # ── Control ───────────────────────────────────────────────────────────────
362
+
363
+ def suspend(self, reason: str) -> None:
364
+ """Mark the agent as suspended (waiting for external signal)."""
365
+ self.is_suspended = True
366
+ self._record(AgentStepKind.SUSPEND, reason)
367
+
368
+ def increment_iteration(self) -> None:
369
+ """Increment the OODA cycle counter. Call once per full OODA cycle."""
370
+ self._iteration_count += 1
371
+
372
+ def budget_exceeded(self) -> bool:
373
+ """True if the cumulative token count meets or exceeds the budget."""
374
+ budget = self._config.token_budget
375
+ if budget <= 0:
376
+ return False
377
+ total = (
378
+ self._trajectory.total_input_tokens
379
+ + self._trajectory.total_output_tokens
380
+ )
381
+ return total >= budget
382
+
383
+ def iteration_limit_reached(self) -> bool:
384
+ """True if the OODA cycle count meets or exceeds max_iterations.
385
+
386
+ Returns False when max_iterations is 0 (unlimited), matching Go/TS/Rust.
387
+ """
388
+ if self._config.max_iterations <= 0:
389
+ return False
390
+ return self._iteration_count >= self._config.max_iterations
391
+
392
+ # ── Trajectory accessors ──────────────────────────────────────────────────
393
+
394
+ def get_trajectory(self) -> AgentTrajectory:
395
+ """Return the in-progress ``AgentTrajectory`` object."""
396
+ return self._trajectory
397
+
398
+ def finalize_trajectory(self, outcome: str, detail: str = "") -> Dict[str, Any]:
399
+ """
400
+ Finalise the trajectory with an outcome and return it as a dict.
401
+
402
+ :param outcome: "completed", "error", "timeout", "budget_exceeded", etc.
403
+ :param detail: Human-readable detail.
404
+ :returns: ``AgentTrajectory.to_dict()``
405
+ """
406
+ self._trajectory.complete(outcome=outcome, detail=detail)
407
+ return self._trajectory.to_dict()
@@ -607,8 +607,8 @@ class Actor(betterproto.Message):
607
607
 
608
608
  ## Migration Strategy
609
609
  ```
610
- Version 1 → 2: Add new field with default value (backward compatible, no migration)
611
- Version 2 → 3: Remove deprecated field (backward compatible, no migration)
610
+ Version 1 → 2: Add new field with default value (no migration needed)
611
+ Version 2 → 3: Remove field (no migration needed)
612
612
  Version 3 → 4: Change field type (BREAKING - requires explicit migration function)
613
613
  Version 4 → 5: Restructure nested fields (BREAKING - requires migration)
614
614
  ```
@@ -771,7 +771,7 @@ class ActorConfig(betterproto.Message):
771
771
  // Version 1: Simple timeout config
772
772
  properties: {"timeout_ms": 5000}
773
773
 
774
- // Version 2: Added retry config (backward compatible)
774
+ // Version 2: Added retry config
775
775
  properties: {
776
776
  "timeout_ms": 5000,
777
777
  "max_retries": 3, // NEW: defaults to 3
@@ -278,8 +278,7 @@ class ActorMetrics(betterproto.Message):
278
278
 
279
279
  ## Purpose
280
280
  Tracks actor lifecycle and message routing/delivery metrics that can be updated by ActorRegistry
281
- without depending on Node. These metrics are also synced to NodeMetrics
282
- via NodeMetricsUpdater for backward compatibility.
281
+ without depending on Node. These metrics are also synced to NodeMetrics via NodeMetricsUpdater.
283
282
 
284
283
  ## Design
285
284
  Proto-first design: All actor/message metrics are defined in proto for:
@@ -1,5 +1,5 @@
1
1
  # Generated by the protocol buffer compiler. DO NOT EDIT!
2
- # sources: plexspaces/v1/node/node.proto, plexspaces/v1/node/outbound.proto, plexspaces/v1/node/release.proto, plexspaces/v1/node/types.proto
2
+ # sources: plexspaces/v1/node/node.proto, plexspaces/v1/node/outbound.proto, plexspaces/v1/node/release.proto
3
3
  # plugin: python-betterproto
4
4
  # This file has been @generated
5
5
 
@@ -148,93 +148,6 @@ class MemberState(betterproto.Enum):
148
148
  LEFT = 4
149
149
 
150
150
 
151
- class ShutdownErrorCode(betterproto.Enum):
152
- """Shutdown coordinator error codes"""
153
-
154
- SHUTDOWN_ERROR_UNSPECIFIED = 0
155
- SHUTDOWN_ERROR_PHASE_FAILED = 1
156
- """A shutdown phase failed (phase name + error in message)"""
157
-
158
- SHUTDOWN_ERROR_TIMEOUT = 2
159
- """Shutdown sequence timed out"""
160
-
161
- SHUTDOWN_ERROR_OTHER = 3
162
- """Other shutdown error"""
163
-
164
-
165
- class HealthCheckErrorCode(betterproto.Enum):
166
- """Health check error codes"""
167
-
168
- HEALTH_CHECK_ERROR_UNSPECIFIED = 0
169
- HEALTH_CHECK_ERROR_CHECK_FAILED = 1
170
- """A health check returned a failure result"""
171
-
172
- HEALTH_CHECK_ERROR_TIMEOUT = 2
173
- """Health check did not complete within deadline"""
174
-
175
- HEALTH_CHECK_ERROR_OTHER = 3
176
- """Unexpected error running a health check"""
177
-
178
-
179
- class BackgroundSchedulerErrorCode(betterproto.Enum):
180
- """Background scheduler error codes"""
181
-
182
- BACKGROUND_SCHEDULER_ERROR_UNSPECIFIED = 0
183
- BACKGROUND_SCHEDULER_ERROR_ALREADY_STARTED = 1
184
- """Scheduler already running for this node in the current process"""
185
-
186
- BACKGROUND_SCHEDULER_ERROR_LOCK_ERROR = 2
187
- """Lock manager error (lease acquisition)"""
188
-
189
- BACKGROUND_SCHEDULER_ERROR_CHANNEL_ERROR = 3
190
- """Internal channel error"""
191
-
192
- BACKGROUND_SCHEDULER_ERROR_STATE_STORE_ERROR = 4
193
- """State store error"""
194
-
195
- BACKGROUND_SCHEDULER_ERROR_NODE_SELECTION_ERROR = 5
196
- """Node selection error during scheduling"""
197
-
198
-
199
- class CapacityTrackerErrorCode(betterproto.Enum):
200
- """Capacity tracker error codes"""
201
-
202
- CAPACITY_TRACKER_ERROR_UNSPECIFIED = 0
203
- CAPACITY_TRACKER_ERROR_REGISTRY_ERROR = 1
204
- """ObjectRegistry error reading capacity data"""
205
-
206
- CAPACITY_TRACKER_ERROR_INVALID_DATA = 2
207
- """Capacity data is malformed or out of range"""
208
-
209
-
210
- class NodeSelectionErrorCode(betterproto.Enum):
211
- """Node selection error codes"""
212
-
213
- NODE_SELECTION_ERROR_UNSPECIFIED = 0
214
- NODE_SELECTION_ERROR_NO_MATCHING_NODE = 1
215
- """No node matched the resource requirements"""
216
-
217
- NODE_SELECTION_ERROR_INVALID_REQUIREMENTS = 2
218
- """Resource requirements are invalid or contradictory"""
219
-
220
-
221
- class TaskRouterErrorCode(betterproto.Enum):
222
- """Task router error codes"""
223
-
224
- TASK_ROUTER_ERROR_UNSPECIFIED = 0
225
- TASK_ROUTER_ERROR_GROUP_NOT_FOUND = 1
226
- """Shard group not found"""
227
-
228
- TASK_ROUTER_ERROR_NO_ACTORS_IN_GROUP = 2
229
- """Shard group has no actors registered"""
230
-
231
- TASK_ROUTER_ERROR_CHANNEL_ERROR = 3
232
- """Internal channel error"""
233
-
234
- TASK_ROUTER_ERROR_INVALID_STRATEGY = 4
235
- """Routing strategy is not valid for this request"""
236
-
237
-
238
151
  @dataclass(eq=False, repr=False)
239
152
  class HttpRetryPolicy(betterproto.Message):
240
153
  """
@@ -979,6 +892,15 @@ class SecurityConfig(betterproto.Message):
979
892
  Never use in production - always keep auth enabled.
980
893
  """
981
894
 
895
+ oidc: "__security_v1__.OidcConfig" = betterproto.message_field(6)
896
+ """
897
+ OIDC/OAuth2 configuration for dashboard UI login.
898
+
899
+ Only active when disable_auth = false. In non-auth mode everyone is admin.
900
+ Supports any OIDC-compliant provider (Google, GitHub, Okta, Auth0, etc.)
901
+ Client secret should be set via PLEXSPACES_OIDC_CLIENT_SECRET env var.
902
+ """
903
+
982
904
 
983
905
  @dataclass(eq=False, repr=False)
984
906
  class ApplicationConfig(betterproto.Message):