runspace 0.2.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 (120) hide show
  1. runspace/__init__.py +11 -0
  2. runspace/contracts/__init__.py +69 -0
  3. runspace/contracts/chat.py +80 -0
  4. runspace/contracts/runtime.py +94 -0
  5. runspace/contracts/scheduling.py +239 -0
  6. runspace/contracts/tool.py +68 -0
  7. runspace/contracts/workspace.py +91 -0
  8. runspace/helpers/__init__.py +0 -0
  9. runspace/helpers/bootstrap.py +257 -0
  10. runspace/helpers/documents/__init__.py +0 -0
  11. runspace/helpers/documents/article_reader.py +290 -0
  12. runspace/helpers/documents/branded_pdf.py +244 -0
  13. runspace/helpers/documents/store.py +113 -0
  14. runspace/helpers/messaging/__init__.py +0 -0
  15. runspace/helpers/messaging/messages.py +32 -0
  16. runspace/helpers/session/__init__.py +0 -0
  17. runspace/helpers/session/store.py +102 -0
  18. runspace/helpers/supabase_db.py +161 -0
  19. runspace/helpers/supabase_exec_sql.sql +42 -0
  20. runspace/helpers/web_crawler/__init__.py +40 -0
  21. runspace/helpers/web_crawler/core.py +292 -0
  22. runspace/ingestion/README.md +67 -0
  23. runspace/ingestion/__init__.py +39 -0
  24. runspace/ingestion/_redact.py +29 -0
  25. runspace/ingestion/_render.py +267 -0
  26. runspace/ingestion/buffer.py +126 -0
  27. runspace/ingestion/pairing.py +378 -0
  28. runspace/ingestion/polling.py +299 -0
  29. runspace/ingestion/routes.py +69 -0
  30. runspace/ingestion/telegram.py +1299 -0
  31. runspace/ingestion/transport.py +184 -0
  32. runspace/protocols/README.md +119 -0
  33. runspace/protocols/__init__.py +133 -0
  34. runspace/protocols/clock.py +92 -0
  35. runspace/protocols/config.py +342 -0
  36. runspace/protocols/conversations/__init__.py +32 -0
  37. runspace/protocols/conversations/in_memory.py +121 -0
  38. runspace/protocols/conversations/models.py +66 -0
  39. runspace/protocols/conversations/protocol.py +71 -0
  40. runspace/protocols/conversations/schema.sql +35 -0
  41. runspace/protocols/conversations/supabase_conversations.py +187 -0
  42. runspace/protocols/embeddings/__init__.py +30 -0
  43. runspace/protocols/embeddings/fixture.py +65 -0
  44. runspace/protocols/embeddings/openai_compat.py +48 -0
  45. runspace/protocols/embeddings/protocol.py +52 -0
  46. runspace/protocols/file_storage/__init__.py +37 -0
  47. runspace/protocols/file_storage/local.py +165 -0
  48. runspace/protocols/file_storage/protocol.py +78 -0
  49. runspace/protocols/file_storage/supabase.py +146 -0
  50. runspace/protocols/prompt/__init__.py +25 -0
  51. runspace/protocols/prompt/envelope.py +49 -0
  52. runspace/protocols/prompt/flatten.py +124 -0
  53. runspace/protocols/registry.py +146 -0
  54. runspace/protocols/sandbox_lint.py +126 -0
  55. runspace/protocols/store/__init__.py +15 -0
  56. runspace/protocols/store/file_store.py +134 -0
  57. runspace/protocols/store/in_memory.py +82 -0
  58. runspace/protocols/store/protocol.py +58 -0
  59. runspace/protocols/store/supabase_store.py +106 -0
  60. runspace/protocols/transcriber.py +44 -0
  61. runspace/protocols/transport/__init__.py +27 -0
  62. runspace/protocols/transport/file_inbox.py +91 -0
  63. runspace/protocols/transport/protocol.py +74 -0
  64. runspace/protocols/transport/telegram.py +123 -0
  65. runspace/protocols/vision/__init__.py +23 -0
  66. runspace/protocols/vision/codex_vision.py +156 -0
  67. runspace/protocols/vision/fixture_vision.py +64 -0
  68. runspace/protocols/vision/protocol.py +38 -0
  69. runspace/py.typed +0 -0
  70. runspace/runspace_cli/__init__.py +90 -0
  71. runspace/templates/widgets.md +70 -0
  72. runspace/workspace/README.md +83 -0
  73. runspace/workspace/__init__.py +31 -0
  74. runspace/workspace/backend/__init__.py +25 -0
  75. runspace/workspace/backend/_mcp_ui.py +106 -0
  76. runspace/workspace/backend/activity_log.py +73 -0
  77. runspace/workspace/backend/app_registry.py +415 -0
  78. runspace/workspace/backend/attachments.py +224 -0
  79. runspace/workspace/backend/bootstrap.py +402 -0
  80. runspace/workspace/backend/file_extractors.py +154 -0
  81. runspace/workspace/backend/gateway.py +2263 -0
  82. runspace/workspace/backend/history_sqlite.py +142 -0
  83. runspace/workspace/backend/media.py +59 -0
  84. runspace/workspace/backend/messaging.py +455 -0
  85. runspace/workspace/backend/messaging_sqlite.py +411 -0
  86. runspace/workspace/backend/models.py +30 -0
  87. runspace/workspace/backend/pricing.py +94 -0
  88. runspace/workspace/backend/registry.py +246 -0
  89. runspace/workspace/backend/response_filter.py +41 -0
  90. runspace/workspace/backend/routines_store.py +362 -0
  91. runspace/workspace/backend/runners/__init__.py +0 -0
  92. runspace/workspace/backend/runners/ab.py +122 -0
  93. runspace/workspace/backend/runners/base.py +104 -0
  94. runspace/workspace/backend/runners/executor.py +133 -0
  95. runspace/workspace/backend/runners/loader.py +123 -0
  96. runspace/workspace/backend/runners/workload.py +166 -0
  97. runspace/workspace/backend/runtimes/__init__.py +20 -0
  98. runspace/workspace/backend/runtimes/agentino.py +425 -0
  99. runspace/workspace/backend/runtimes/claude_code.py +269 -0
  100. runspace/workspace/backend/runtimes/codex.py +230 -0
  101. runspace/workspace/backend/runtimes/mcp_harness.py +220 -0
  102. runspace/workspace/backend/runtimes/openclaw.py +277 -0
  103. runspace/workspace/backend/runtimes/pi.py +298 -0
  104. runspace/workspace/backend/scoring/__init__.py +0 -0
  105. runspace/workspace/backend/scoring/base.py +51 -0
  106. runspace/workspace/backend/scoring/match.py +55 -0
  107. runspace/workspace/backend/sessions.py +314 -0
  108. runspace/workspace/backend/tools_usage.py +145 -0
  109. runspace/workspace/backend/widget_validator.py +128 -0
  110. runspace/workspace/cli/__init__.py +10 -0
  111. runspace/workspace/cli/__main__.py +81 -0
  112. runspace/workspace/cli/init_cmd.py +293 -0
  113. runspace/workspace/plugin.py +54 -0
  114. runspace/workspace/serve.py +74 -0
  115. runspace-0.2.0.dist-info/METADATA +297 -0
  116. runspace-0.2.0.dist-info/RECORD +120 -0
  117. runspace-0.2.0.dist-info/WHEEL +4 -0
  118. runspace-0.2.0.dist-info/entry_points.txt +2 -0
  119. runspace-0.2.0.dist-info/licenses/LICENSE +202 -0
  120. runspace-0.2.0.dist-info/licenses/NOTICE +17 -0
runspace/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Runspace — a workspace runtime for LLM agents.
2
+
3
+ Subpackages:
4
+ contracts wire shapes shared between a host app and an agent runtime
5
+ protocols swappable adapters behind typing.Protocol definitions
6
+ workspace the workspace product: gateway, registry, runtimes
7
+ ingestion inbound channels
8
+ helpers session, messaging and document utilities
9
+ """
10
+
11
+ __version__ = "0.2.0"
@@ -0,0 +1,69 @@
1
+ """Runtime-agnostic contracts.
2
+
3
+ Pydantic shapes + schema validators that any runtime (agentino,
4
+ openclaw, future) must produce/consume to participate in the workspace
5
+ chat protocol. No agentino framework imports here — that's the
6
+ load-bearing rule.
7
+
8
+ Migrated from `workspace/backend/models.py` (2026-05-06) so a second
9
+ runtime can import the wire shapes without dragging in WorkspaceGateway
10
+ + AppRegistry + the agentino Agent class.
11
+ """
12
+
13
+ from .chat import (
14
+ AttachmentInput,
15
+ ChatRequest,
16
+ ChatResponse,
17
+ FileAttachmentResponse,
18
+ RoutineCreateRequest,
19
+ RoutineDelivery,
20
+ )
21
+ from .runtime import AgentRuntime, AgentTurnDelta, AgentTurnResult, Attachment
22
+ from .scheduling import (
23
+ CronJob,
24
+ Delivery,
25
+ JobStatus,
26
+ Payload,
27
+ Schedule,
28
+ ScheduleKind,
29
+ )
30
+ from .tool import AgentTool
31
+ from .workspace import (
32
+ AppConfig,
33
+ ChannelConfig,
34
+ ProviderConfig,
35
+ UserConfig,
36
+ WorkspaceConfig,
37
+ load_workspace,
38
+ )
39
+
40
+ __all__ = [
41
+ # chat protocol
42
+ "AttachmentInput",
43
+ "ChatRequest",
44
+ "ChatResponse",
45
+ "FileAttachmentResponse",
46
+ "RoutineCreateRequest",
47
+ "RoutineDelivery",
48
+ # tool contract
49
+ "AgentTool",
50
+ # runtime contract (the dispatcher seam)
51
+ "AgentRuntime",
52
+ "AgentTurnResult",
53
+ "AgentTurnDelta",
54
+ "Attachment",
55
+ # workspace.yml schema
56
+ "AppConfig",
57
+ "ChannelConfig",
58
+ "ProviderConfig",
59
+ "UserConfig",
60
+ "WorkspaceConfig",
61
+ "load_workspace",
62
+ # scheduling primitives
63
+ "CronJob",
64
+ "Delivery",
65
+ "JobStatus",
66
+ "Payload",
67
+ "Schedule",
68
+ "ScheduleKind",
69
+ ]
@@ -0,0 +1,80 @@
1
+ """Wire shapes for the workspace chat protocol.
2
+
3
+ Both agentino's WorkspaceGateway and any future runtime's HTTP layer
4
+ produce/consume these. Pure data — no framework imports.
5
+
6
+ Originally lived at `workspace/backend/models.py`; moved 2026-05-06 to
7
+ make it a runtime-agnostic contract.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Literal
13
+
14
+ from pydantic import BaseModel
15
+
16
+
17
+ class AttachmentInput(BaseModel):
18
+ name: str
19
+ type: str # MIME type
20
+ size: int # bytes
21
+ content: str = "" # base64-encoded file content (text files decoded for agent)
22
+
23
+
24
+ class ChatRequest(BaseModel):
25
+ app_id: str | None = None
26
+ agent_id: str | None = None # alias for app_id (backward compat)
27
+ message: str = ""
28
+ session_id: str = ""
29
+ thread_id: str | None = None
30
+ sender_name: str | None = None # real user name from JWT (per-request identity)
31
+ file_ids: list[str] = [] # uploaded file references (from /upload endpoint)
32
+ # Legacy (still supported)
33
+ media_base64: str | None = None
34
+ media_mime: str | None = None
35
+ attachments: list[AttachmentInput] = []
36
+
37
+ @property
38
+ def resolved_app_id(self) -> str:
39
+ return self.app_id or self.agent_id or ""
40
+
41
+
42
+ class FileAttachmentResponse(BaseModel):
43
+ name: str
44
+ url: str
45
+ size: int
46
+ type: str
47
+
48
+
49
+ class ChatResponse(BaseModel):
50
+ app_id: str
51
+ app_name: str
52
+ response: str
53
+ session_id: str
54
+ tools_used: list[str] = []
55
+ attachments: list[FileAttachmentResponse] = []
56
+
57
+
58
+ class RoutineDelivery(BaseModel):
59
+ """Where (if anywhere) the agent's reply goes when a routine fires.
60
+
61
+ Three modes:
62
+ - channel: post to a chat channel (target = channel slug)
63
+ - dm: send as a direct message (target = user_id)
64
+ - silent: run the prompt, don't post anywhere (target ignored).
65
+ The agent still uses tools and has side effects via
66
+ those tools; only the *announce* part is suppressed.
67
+ """
68
+
69
+ kind: Literal["channel", "dm", "silent"]
70
+ target: str | None = None
71
+
72
+
73
+ class RoutineCreateRequest(BaseModel):
74
+ agent_id: str
75
+ schedule: str
76
+ prompt: str
77
+ description: str = ""
78
+ enabled: bool = True
79
+ # Required. Caller picks where the routine's output lands.
80
+ delivery: RoutineDelivery
@@ -0,0 +1,94 @@
1
+ """AgentRuntime contract — the seam each runtime implements.
2
+
3
+ Mirrors the Protocol that lives today at
4
+ `acme/platform/apps/api/agent_runtime/protocol.py`. Lifting it here
5
+ means a third-party host (acme, initech, future) can declare
6
+ "my dispatcher accepts any AgentRuntime" without depending on acme's
7
+ internal module.
8
+
9
+ Pure data + Protocol. No imports beyond stdlib + dataclasses.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import AsyncIterator
15
+ from dataclasses import dataclass, field
16
+ from typing import Protocol, runtime_checkable
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Attachment:
21
+ """File attachment carried into a turn (e.g. a scanned invoice)."""
22
+
23
+ file_id: str
24
+ original_name: str
25
+ mime_type: str
26
+ size_bytes: int
27
+
28
+
29
+ @dataclass
30
+ class AgentTurnDelta:
31
+ """One streaming chunk from a runtime."""
32
+
33
+ text: str = ""
34
+ is_final: bool = False
35
+ tool_call: dict | None = None
36
+
37
+
38
+ @dataclass
39
+ class AgentTurnResult:
40
+ """Final result of one agent turn — runtime-agnostic."""
41
+
42
+ text: str
43
+ runtime: str # "agentino" | "openclaw" | ...
44
+ tool_calls: list[dict] = field(default_factory=list)
45
+ runtime_session_id: str | None = None
46
+ duration_ms: int = 0
47
+ cost_usd: float | None = None
48
+ error: str | None = None
49
+
50
+
51
+ @runtime_checkable
52
+ class AgentRuntime(Protocol):
53
+ """Every runtime that can serve an agent turn implements this.
54
+
55
+ The dispatcher (`AgentRuntimeRouter` in acme) holds a map
56
+ `{name: AgentRuntime}` and calls `run_turn(...)` on whichever
57
+ runtime the tenant's `workspace.yml apps.<id>.type:` resolves to.
58
+ """
59
+
60
+ name: str # canonical id: "agentino" | "openclaw"
61
+
62
+ async def run_turn(
63
+ self,
64
+ *,
65
+ tenant_id: str,
66
+ agent_id: str,
67
+ session_key: str,
68
+ message: str,
69
+ attachments: list[Attachment] | None = None,
70
+ sender_id: str | None = None,
71
+ channel: str | None = None,
72
+ ) -> AgentTurnResult: ...
73
+
74
+ async def stream_turn(
75
+ self,
76
+ *,
77
+ tenant_id: str,
78
+ agent_id: str,
79
+ session_key: str,
80
+ message: str,
81
+ attachments: list[Attachment] | None = None,
82
+ sender_id: str | None = None,
83
+ channel: str | None = None,
84
+ ) -> AsyncIterator[AgentTurnDelta]:
85
+ """Optional. Subprocess-based runtimes may yield a single final delta."""
86
+ ...
87
+
88
+
89
+ __all__ = [
90
+ "AgentRuntime",
91
+ "AgentTurnResult",
92
+ "AgentTurnDelta",
93
+ "Attachment",
94
+ ]
@@ -0,0 +1,239 @@
1
+ """Scheduling primitives — runtime-agnostic data shapes.
2
+
3
+ Schedule, Payload, Delivery, CronJob and friends are pure dataclasses
4
+ that any runtime can produce or consume. They live here (not in the
5
+ agentino runtime) so that workspace gateway / routine stores can speak
6
+ the same vocabulary regardless of which runtime is hosting an agent.
7
+
8
+ agentino's `agentino.scheduler.core` re-exports these for back-compat
9
+ so existing `from agentino.scheduler import Schedule, ...` keeps working.
10
+ The runtime piece (`CronScheduler`) stays inside agentino — only the
11
+ data layer moves.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from datetime import datetime, timedelta
18
+ from enum import Enum
19
+
20
+ from croniter import croniter
21
+
22
+
23
+ class JobStatus(Enum):
24
+ PENDING = "pending"
25
+ RUNNING = "running"
26
+ COMPLETED = "completed"
27
+ FAILED = "failed"
28
+ DISABLED = "disabled"
29
+
30
+
31
+ class ScheduleKind(Enum):
32
+ AT = "at"
33
+ CRON = "cron"
34
+ EVERY = "every"
35
+
36
+
37
+ @dataclass
38
+ class Schedule:
39
+ kind: ScheduleKind
40
+ at: datetime | None = None
41
+ cron_expr: str | None = None
42
+ every_ms: int | None = None
43
+ timezone: str = "Europe/Nicosia"
44
+
45
+ def next_run(self, after: datetime | None = None) -> datetime | None:
46
+ after = after or datetime.now()
47
+ if self.kind == ScheduleKind.AT:
48
+ if self.at and self.at > after:
49
+ return self.at
50
+ return None
51
+ if self.kind == ScheduleKind.CRON:
52
+ if self.cron_expr:
53
+ try:
54
+ cron = croniter(self.cron_expr, after)
55
+ return cron.get_next(datetime)
56
+ except Exception:
57
+ return None
58
+ if self.kind == ScheduleKind.EVERY:
59
+ if self.every_ms:
60
+ return after + timedelta(milliseconds=self.every_ms)
61
+ return None
62
+
63
+
64
+ @dataclass
65
+ class Delivery:
66
+ channel: str = "whatsapp"
67
+ to: str | None = None
68
+ best_effort: bool = True
69
+
70
+
71
+ @dataclass
72
+ class Payload:
73
+ kind: str
74
+ skill: str | None = None
75
+ template: str | None = None
76
+ message: str | None = None
77
+ data: dict = field(default_factory=dict)
78
+
79
+
80
+ @dataclass
81
+ class CronJob:
82
+ id: str
83
+ tenant_id: str
84
+ name: str
85
+ schedule: Schedule
86
+ payload: Payload
87
+ delivery: Delivery | None = None
88
+
89
+ enabled: bool = True
90
+ status: JobStatus = JobStatus.PENDING
91
+ delete_after_run: bool = False
92
+
93
+ created_at: datetime = field(default_factory=datetime.now)
94
+ next_run_at: datetime | None = None
95
+ last_run_at: datetime | None = None
96
+ last_error: str | None = None
97
+
98
+ consecutive_failures: int = 0
99
+ retry_after: datetime | None = None
100
+
101
+ def __post_init__(self):
102
+ if self.next_run_at is None:
103
+ self.next_run_at = self.schedule.next_run()
104
+
105
+ def calculate_retry_delay(self) -> timedelta:
106
+ delays = [30, 60, 300, 900, 3600]
107
+ idx = min(self.consecutive_failures, len(delays) - 1)
108
+ return timedelta(seconds=delays[idx])
109
+
110
+ def mark_success(self):
111
+ self.status = JobStatus.COMPLETED
112
+ self.last_run_at = datetime.now()
113
+ self.last_error = None
114
+ self.consecutive_failures = 0
115
+ self.retry_after = None
116
+ if self.schedule.kind != ScheduleKind.AT:
117
+ self.next_run_at = self.schedule.next_run(self.last_run_at)
118
+ self.status = JobStatus.PENDING
119
+ else:
120
+ if self.delete_after_run:
121
+ pass
122
+ else:
123
+ self.enabled = False
124
+
125
+ def mark_failure(self, error: str):
126
+ self.status = JobStatus.FAILED
127
+ self.last_run_at = datetime.now()
128
+ self.last_error = error
129
+ self.consecutive_failures += 1
130
+ delay = self.calculate_retry_delay()
131
+ self.retry_after = datetime.now() + delay
132
+ if self.schedule.kind != ScheduleKind.AT:
133
+ self.next_run_at = self.retry_after
134
+ self.status = JobStatus.PENDING
135
+
136
+ def is_due(self, now: datetime | None = None) -> bool:
137
+ now = now or datetime.now()
138
+ if not self.enabled:
139
+ return False
140
+ if self.status == JobStatus.RUNNING:
141
+ return False
142
+
143
+ def to_naive(dt):
144
+ if dt is None:
145
+ return None
146
+ return dt.replace(tzinfo=None) if dt.tzinfo else dt
147
+
148
+ now_naive = to_naive(now)
149
+ if self.retry_after and now_naive < to_naive(self.retry_after):
150
+ return False
151
+ if self.next_run_at and now_naive >= to_naive(self.next_run_at):
152
+ return True
153
+ return False
154
+
155
+ def to_dict(self) -> dict:
156
+ return {
157
+ "id": self.id,
158
+ "tenant_id": self.tenant_id,
159
+ "name": self.name,
160
+ "schedule_kind": self.schedule.kind.value,
161
+ "schedule_at": self.schedule.at.isoformat() if self.schedule.at else None,
162
+ "schedule_cron": self.schedule.cron_expr,
163
+ "schedule_every_ms": self.schedule.every_ms,
164
+ "schedule_timezone": self.schedule.timezone,
165
+ "payload_kind": self.payload.kind,
166
+ "payload_skill": self.payload.skill,
167
+ "payload_template": self.payload.template,
168
+ "payload_message": self.payload.message,
169
+ "payload_data": self.payload.data,
170
+ "delivery_channel": self.delivery.channel if self.delivery else None,
171
+ "delivery_to": self.delivery.to if self.delivery else None,
172
+ "delivery_best_effort": self.delivery.best_effort if self.delivery else True,
173
+ "enabled": self.enabled,
174
+ "status": self.status.value,
175
+ "delete_after_run": self.delete_after_run,
176
+ "created_at": self.created_at.isoformat(),
177
+ "next_run_at": self.next_run_at.isoformat() if self.next_run_at else None,
178
+ "last_run_at": self.last_run_at.isoformat() if self.last_run_at else None,
179
+ "last_error": self.last_error,
180
+ "consecutive_failures": self.consecutive_failures,
181
+ "retry_after": self.retry_after.isoformat() if self.retry_after else None,
182
+ }
183
+
184
+ @classmethod
185
+ def from_dict(cls, data: dict) -> CronJob:
186
+ def _parse_dt(val):
187
+ if not val:
188
+ return None
189
+ dt = datetime.fromisoformat(val) if isinstance(val, str) else val
190
+ return dt.replace(tzinfo=None) if dt.tzinfo else dt
191
+
192
+ schedule = Schedule(
193
+ kind=ScheduleKind(data["schedule_kind"]),
194
+ at=_parse_dt(data.get("schedule_at")),
195
+ cron_expr=data.get("schedule_cron"),
196
+ every_ms=data.get("schedule_every_ms"),
197
+ timezone=data.get("schedule_timezone", "Europe/Nicosia"),
198
+ )
199
+ payload = Payload(
200
+ kind=data["payload_kind"],
201
+ skill=data.get("payload_skill"),
202
+ template=data.get("payload_template"),
203
+ message=data.get("payload_message"),
204
+ data=data.get("payload_data", {}),
205
+ )
206
+ delivery = None
207
+ if data.get("delivery_channel"):
208
+ delivery = Delivery(
209
+ channel=data["delivery_channel"],
210
+ to=data.get("delivery_to"),
211
+ best_effort=data.get("delivery_best_effort", True),
212
+ )
213
+ return cls(
214
+ id=data["id"],
215
+ tenant_id=data["tenant_id"],
216
+ name=data["name"],
217
+ schedule=schedule,
218
+ payload=payload,
219
+ delivery=delivery,
220
+ enabled=data.get("enabled", True),
221
+ status=JobStatus(data.get("status", "pending")),
222
+ delete_after_run=data.get("delete_after_run", False),
223
+ created_at=_parse_dt(data.get("created_at")) or datetime.now(),
224
+ next_run_at=_parse_dt(data.get("next_run_at")),
225
+ last_run_at=_parse_dt(data.get("last_run_at")),
226
+ last_error=data.get("last_error"),
227
+ consecutive_failures=data.get("consecutive_failures", 0),
228
+ retry_after=_parse_dt(data.get("retry_after")),
229
+ )
230
+
231
+
232
+ __all__ = [
233
+ "JobStatus",
234
+ "ScheduleKind",
235
+ "Schedule",
236
+ "Delivery",
237
+ "Payload",
238
+ "CronJob",
239
+ ]
@@ -0,0 +1,68 @@
1
+ """AgentTool contract — runtime-agnostic tool interface.
2
+
3
+ The structural shape every runtime's "tool" must satisfy. Both
4
+ agentino's `@tool`-decorated `Tool` (Python) and openclaw's
5
+ `api.registerTool({ name, parameters, execute })` (TypeScript) fit
6
+ this contract. Defining it here means:
7
+
8
+ 1. Documentation: the field set is fixed, agreed across runtimes.
9
+ Adding a tool = "ship something that satisfies AgentTool".
10
+ 2. Tooling: validators / schema generators can target the contract
11
+ without importing a specific runtime's implementation.
12
+ 3. Future runtimes: a third runtime knows what to expose from day one.
13
+
14
+ This is a Protocol, not a base class — Tool / OpenClaw factory don't
15
+ inherit from it; they satisfy it structurally. Runtime-checkable so
16
+ `isinstance(t, AgentTool)` works for sanity checks if needed.
17
+
18
+ This module deliberately has zero runtime dependencies — it imports
19
+ only from typing and collections.abc. The contract is pure shape.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from collections.abc import Awaitable, Callable
25
+ from typing import Any, Protocol, runtime_checkable
26
+
27
+
28
+ @runtime_checkable
29
+ class AgentTool(Protocol):
30
+ """The minimal contract a runtime's "tool" must satisfy.
31
+
32
+ Mandatory fields:
33
+ - `name`: unique identifier the LLM uses to call the tool.
34
+ - `description`: human + model-readable summary; the LLM picks
35
+ tools by reading this.
36
+ - `parameters`: JSON-Schema dict (or compatible) describing input
37
+ args. Both agentino's pydantic-ish dict and openclaw's TypeBox
38
+ schemas serialize to JSON Schema.
39
+ - `fn` (or callable interface): the actual logic. May be sync or
40
+ async. Implementations may also expose `execute(...)` (openclaw
41
+ style) — runtimes-side adapters bridge whichever shape they use
42
+ to a common call: `tool(args) -> result`.
43
+
44
+ Optional behavioural metadata:
45
+ - `is_read_only`: marks the tool as side-effect-free (safe for
46
+ parallel execution, retry, dry-run).
47
+ - `timeout`: per-call timeout in seconds.
48
+
49
+ Notes:
50
+ - The contract intentionally does NOT prescribe permissions / gates
51
+ — those live in `agents.gates` (workspace.yml) and are enforced
52
+ at the runtime layer, not per-tool.
53
+ - The contract uses `Any` for the result on purpose. Tools return
54
+ strings, dicts, structured envelopes (`{content: [...]}`) — each
55
+ runtime's caller normalises before passing to the LLM. Pinning a
56
+ type here would force runtimes to converge on a single
57
+ normalisation, which is premature.
58
+ """
59
+
60
+ name: str
61
+ description: str
62
+ parameters: dict[str, Any]
63
+ fn: Callable[..., Any] | Callable[..., Awaitable[Any]]
64
+ is_read_only: bool
65
+ timeout: float | None
66
+
67
+
68
+ __all__ = ["AgentTool"]
@@ -0,0 +1,91 @@
1
+ """Workspace.yml schema — runtime-agnostic validation + access.
2
+
3
+ Both agentino (when AppRegistry loads a tenant) and openclaw (when its
4
+ sync script reads each agent's metadata) consume the same workspace.yml.
5
+ This module defines the schema in pydantic + a minimal loader.
6
+
7
+ Goal: when a third runtime arrives, it imports `WorkspaceConfig` from
8
+ here and gets the same view of `apps:`, `users:`, `channels:`,
9
+ `routines:`, etc. without copying the parser.
10
+
11
+ Lightweight on purpose — full validation is enforced at use-time
12
+ (AppRegistry raises if `soul:` doesn't resolve, etc.). This schema
13
+ exists to prevent typos and document the shape.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from pathlib import Path
19
+ from typing import Any, Literal
20
+
21
+ import yaml # type: ignore[import-untyped]
22
+ from pydantic import BaseModel, Field
23
+
24
+
25
+ class AppConfig(BaseModel):
26
+ """One agent in the `apps:` block of workspace.yml."""
27
+
28
+ name: str = ""
29
+ role: str = ""
30
+ avatar: str = ""
31
+ color: str = ""
32
+ group: Literal["backoffice", "customer", "default"] = "default"
33
+ type: Literal["agentino", "openclaw", "codex", "claude_code", "pi", "http", "webhook"] = (
34
+ "agentino"
35
+ )
36
+ soul: str | None = None
37
+ tools: str | None = None
38
+ shared_tools: str | list[str] | None = None
39
+ model: str | None = None
40
+ endpoint: str | None = None
41
+ enabled: bool = True
42
+ gates: dict[str, Any] | None = None
43
+ # New runtime-side fields used by openclaw
44
+ openclaw_plugin: str | None = None
45
+ openclaw_skills: list[str] = Field(default_factory=list)
46
+
47
+
48
+ class UserConfig(BaseModel):
49
+ name: str = ""
50
+ role: str = ""
51
+ avatar: str = ""
52
+ default: bool = False
53
+
54
+
55
+ class ChannelConfig(BaseModel):
56
+ id: str
57
+ label: str = ""
58
+ icon: str = ""
59
+ type: str = "chat"
60
+
61
+
62
+ class ProviderConfig(BaseModel):
63
+ base_url: str = ""
64
+ api_key: str = ""
65
+ provider: str = ""
66
+
67
+
68
+ class WorkspaceConfig(BaseModel):
69
+ """Top-level workspace.yml shape. `extra='allow'` because tenants
70
+ legitimately add custom blocks (settings, automation, persona…)
71
+ that are read by tenant-specific code paths."""
72
+
73
+ name: str = ""
74
+ icon: str = ""
75
+ brand_color: str = ""
76
+ sidebar_color: str = ""
77
+ tenant_id: str | None = None
78
+ apps: dict[str, AppConfig] = Field(default_factory=dict)
79
+ users: dict[str, UserConfig] = Field(default_factory=dict)
80
+ channels: list[ChannelConfig] = Field(default_factory=list)
81
+ providers: dict[str, ProviderConfig] = Field(default_factory=dict)
82
+
83
+ model_config = {"extra": "allow"}
84
+
85
+
86
+ def load_workspace(path: str | Path) -> WorkspaceConfig:
87
+ """Read + validate a workspace.yml. Raises pydantic ValidationError
88
+ on shape errors. Pure I/O + parse, no runtime side effects."""
89
+ p = Path(path)
90
+ raw = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
91
+ return WorkspaceConfig.model_validate(raw)
File without changes