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.
- runspace/__init__.py +11 -0
- runspace/contracts/__init__.py +69 -0
- runspace/contracts/chat.py +80 -0
- runspace/contracts/runtime.py +94 -0
- runspace/contracts/scheduling.py +239 -0
- runspace/contracts/tool.py +68 -0
- runspace/contracts/workspace.py +91 -0
- runspace/helpers/__init__.py +0 -0
- runspace/helpers/bootstrap.py +257 -0
- runspace/helpers/documents/__init__.py +0 -0
- runspace/helpers/documents/article_reader.py +290 -0
- runspace/helpers/documents/branded_pdf.py +244 -0
- runspace/helpers/documents/store.py +113 -0
- runspace/helpers/messaging/__init__.py +0 -0
- runspace/helpers/messaging/messages.py +32 -0
- runspace/helpers/session/__init__.py +0 -0
- runspace/helpers/session/store.py +102 -0
- runspace/helpers/supabase_db.py +161 -0
- runspace/helpers/supabase_exec_sql.sql +42 -0
- runspace/helpers/web_crawler/__init__.py +40 -0
- runspace/helpers/web_crawler/core.py +292 -0
- runspace/ingestion/README.md +67 -0
- runspace/ingestion/__init__.py +39 -0
- runspace/ingestion/_redact.py +29 -0
- runspace/ingestion/_render.py +267 -0
- runspace/ingestion/buffer.py +126 -0
- runspace/ingestion/pairing.py +378 -0
- runspace/ingestion/polling.py +299 -0
- runspace/ingestion/routes.py +69 -0
- runspace/ingestion/telegram.py +1299 -0
- runspace/ingestion/transport.py +184 -0
- runspace/protocols/README.md +119 -0
- runspace/protocols/__init__.py +133 -0
- runspace/protocols/clock.py +92 -0
- runspace/protocols/config.py +342 -0
- runspace/protocols/conversations/__init__.py +32 -0
- runspace/protocols/conversations/in_memory.py +121 -0
- runspace/protocols/conversations/models.py +66 -0
- runspace/protocols/conversations/protocol.py +71 -0
- runspace/protocols/conversations/schema.sql +35 -0
- runspace/protocols/conversations/supabase_conversations.py +187 -0
- runspace/protocols/embeddings/__init__.py +30 -0
- runspace/protocols/embeddings/fixture.py +65 -0
- runspace/protocols/embeddings/openai_compat.py +48 -0
- runspace/protocols/embeddings/protocol.py +52 -0
- runspace/protocols/file_storage/__init__.py +37 -0
- runspace/protocols/file_storage/local.py +165 -0
- runspace/protocols/file_storage/protocol.py +78 -0
- runspace/protocols/file_storage/supabase.py +146 -0
- runspace/protocols/prompt/__init__.py +25 -0
- runspace/protocols/prompt/envelope.py +49 -0
- runspace/protocols/prompt/flatten.py +124 -0
- runspace/protocols/registry.py +146 -0
- runspace/protocols/sandbox_lint.py +126 -0
- runspace/protocols/store/__init__.py +15 -0
- runspace/protocols/store/file_store.py +134 -0
- runspace/protocols/store/in_memory.py +82 -0
- runspace/protocols/store/protocol.py +58 -0
- runspace/protocols/store/supabase_store.py +106 -0
- runspace/protocols/transcriber.py +44 -0
- runspace/protocols/transport/__init__.py +27 -0
- runspace/protocols/transport/file_inbox.py +91 -0
- runspace/protocols/transport/protocol.py +74 -0
- runspace/protocols/transport/telegram.py +123 -0
- runspace/protocols/vision/__init__.py +23 -0
- runspace/protocols/vision/codex_vision.py +156 -0
- runspace/protocols/vision/fixture_vision.py +64 -0
- runspace/protocols/vision/protocol.py +38 -0
- runspace/py.typed +0 -0
- runspace/runspace_cli/__init__.py +90 -0
- runspace/templates/widgets.md +70 -0
- runspace/workspace/README.md +83 -0
- runspace/workspace/__init__.py +31 -0
- runspace/workspace/backend/__init__.py +25 -0
- runspace/workspace/backend/_mcp_ui.py +106 -0
- runspace/workspace/backend/activity_log.py +73 -0
- runspace/workspace/backend/app_registry.py +415 -0
- runspace/workspace/backend/attachments.py +224 -0
- runspace/workspace/backend/bootstrap.py +402 -0
- runspace/workspace/backend/file_extractors.py +154 -0
- runspace/workspace/backend/gateway.py +2263 -0
- runspace/workspace/backend/history_sqlite.py +142 -0
- runspace/workspace/backend/media.py +59 -0
- runspace/workspace/backend/messaging.py +455 -0
- runspace/workspace/backend/messaging_sqlite.py +411 -0
- runspace/workspace/backend/models.py +30 -0
- runspace/workspace/backend/pricing.py +94 -0
- runspace/workspace/backend/registry.py +246 -0
- runspace/workspace/backend/response_filter.py +41 -0
- runspace/workspace/backend/routines_store.py +362 -0
- runspace/workspace/backend/runners/__init__.py +0 -0
- runspace/workspace/backend/runners/ab.py +122 -0
- runspace/workspace/backend/runners/base.py +104 -0
- runspace/workspace/backend/runners/executor.py +133 -0
- runspace/workspace/backend/runners/loader.py +123 -0
- runspace/workspace/backend/runners/workload.py +166 -0
- runspace/workspace/backend/runtimes/__init__.py +20 -0
- runspace/workspace/backend/runtimes/agentino.py +425 -0
- runspace/workspace/backend/runtimes/claude_code.py +269 -0
- runspace/workspace/backend/runtimes/codex.py +230 -0
- runspace/workspace/backend/runtimes/mcp_harness.py +220 -0
- runspace/workspace/backend/runtimes/openclaw.py +277 -0
- runspace/workspace/backend/runtimes/pi.py +298 -0
- runspace/workspace/backend/scoring/__init__.py +0 -0
- runspace/workspace/backend/scoring/base.py +51 -0
- runspace/workspace/backend/scoring/match.py +55 -0
- runspace/workspace/backend/sessions.py +314 -0
- runspace/workspace/backend/tools_usage.py +145 -0
- runspace/workspace/backend/widget_validator.py +128 -0
- runspace/workspace/cli/__init__.py +10 -0
- runspace/workspace/cli/__main__.py +81 -0
- runspace/workspace/cli/init_cmd.py +293 -0
- runspace/workspace/plugin.py +54 -0
- runspace/workspace/serve.py +74 -0
- runspace-0.2.0.dist-info/METADATA +297 -0
- runspace-0.2.0.dist-info/RECORD +120 -0
- runspace-0.2.0.dist-info/WHEEL +4 -0
- runspace-0.2.0.dist-info/entry_points.txt +2 -0
- runspace-0.2.0.dist-info/licenses/LICENSE +202 -0
- 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
|