flatmachines 1.0.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.
- flatmachines/__init__.py +136 -0
- flatmachines/actions.py +408 -0
- flatmachines/adapters/__init__.py +38 -0
- flatmachines/adapters/flatagent.py +86 -0
- flatmachines/adapters/pi_agent_bridge.py +127 -0
- flatmachines/adapters/pi_agent_runner.mjs +99 -0
- flatmachines/adapters/smolagents.py +125 -0
- flatmachines/agents.py +144 -0
- flatmachines/assets/MACHINES.md +141 -0
- flatmachines/assets/README.md +11 -0
- flatmachines/assets/__init__.py +0 -0
- flatmachines/assets/flatagent.d.ts +219 -0
- flatmachines/assets/flatagent.schema.json +271 -0
- flatmachines/assets/flatagent.slim.d.ts +58 -0
- flatmachines/assets/flatagents-runtime.d.ts +523 -0
- flatmachines/assets/flatagents-runtime.schema.json +281 -0
- flatmachines/assets/flatagents-runtime.slim.d.ts +187 -0
- flatmachines/assets/flatmachine.d.ts +403 -0
- flatmachines/assets/flatmachine.schema.json +620 -0
- flatmachines/assets/flatmachine.slim.d.ts +106 -0
- flatmachines/assets/profiles.d.ts +140 -0
- flatmachines/assets/profiles.schema.json +93 -0
- flatmachines/assets/profiles.slim.d.ts +26 -0
- flatmachines/backends.py +222 -0
- flatmachines/distributed.py +835 -0
- flatmachines/distributed_hooks.py +351 -0
- flatmachines/execution.py +638 -0
- flatmachines/expressions/__init__.py +60 -0
- flatmachines/expressions/cel.py +101 -0
- flatmachines/expressions/simple.py +166 -0
- flatmachines/flatmachine.py +1263 -0
- flatmachines/hooks.py +381 -0
- flatmachines/locking.py +69 -0
- flatmachines/monitoring.py +505 -0
- flatmachines/persistence.py +213 -0
- flatmachines/run.py +117 -0
- flatmachines/utils.py +166 -0
- flatmachines/validation.py +79 -0
- flatmachines-1.0.0.dist-info/METADATA +390 -0
- flatmachines-1.0.0.dist-info/RECORD +41 -0
- flatmachines-1.0.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import fcntl
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Any, Dict, Optional, List
|
|
7
|
+
from dataclasses import dataclass, asdict, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
import aiofiles
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class MachineSnapshot:
|
|
16
|
+
"""Wire format for machine checkpoints."""
|
|
17
|
+
execution_id: str
|
|
18
|
+
machine_name: str
|
|
19
|
+
spec_version: str
|
|
20
|
+
current_state: str
|
|
21
|
+
context: Dict[str, Any]
|
|
22
|
+
step: int
|
|
23
|
+
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
24
|
+
event: Optional[str] = None # The event that triggered this checkpoint (machine_start, etc)
|
|
25
|
+
output: Optional[Dict[str, Any]] = None # Output if captured at state_exit/machine_end
|
|
26
|
+
total_api_calls: Optional[int] = None # Cumulative API calls
|
|
27
|
+
total_cost: Optional[float] = None # Cumulative cost
|
|
28
|
+
# Lineage (v0.4.0)
|
|
29
|
+
parent_execution_id: Optional[str] = None # ID of launcher machine if this was launched
|
|
30
|
+
# Outbox pattern (v0.4.0)
|
|
31
|
+
pending_launches: Optional[List[Dict[str, Any]]] = None # LaunchIntent dicts awaiting completion
|
|
32
|
+
|
|
33
|
+
class PersistenceBackend(ABC):
|
|
34
|
+
"""Abstract storage backend for checkpoints."""
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
async def save(self, key: str, value: bytes) -> None:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
async def load(self, key: str) -> Optional[bytes]:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
async def delete(self, key: str) -> None:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
class LocalFileBackend(PersistenceBackend):
|
|
49
|
+
"""File-based persistence backend."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, base_dir: str = ".checkpoints"):
|
|
52
|
+
self.base_dir = Path(base_dir)
|
|
53
|
+
self.base_dir.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
|
|
55
|
+
def _validate_key(self, key: str) -> None:
|
|
56
|
+
"""Validate key to prevent path traversal attacks."""
|
|
57
|
+
if '..' in key or key.startswith('/'):
|
|
58
|
+
raise ValueError(f"Invalid checkpoint key: {key}")
|
|
59
|
+
|
|
60
|
+
async def save(self, key: str, value: bytes) -> None:
|
|
61
|
+
self._validate_key(key)
|
|
62
|
+
path = self.base_dir / key
|
|
63
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
|
|
65
|
+
# Write to temp file first for atomicity
|
|
66
|
+
temp_path = path.parent / f".{path.name}.tmp"
|
|
67
|
+
async with aiofiles.open(temp_path, 'wb') as f:
|
|
68
|
+
await f.write(value)
|
|
69
|
+
|
|
70
|
+
# Atomic rename (safe on POSIX and Windows)
|
|
71
|
+
temp_path.replace(path)
|
|
72
|
+
|
|
73
|
+
async def load(self, key: str) -> Optional[bytes]:
|
|
74
|
+
self._validate_key(key)
|
|
75
|
+
path = self.base_dir / key
|
|
76
|
+
if not path.exists():
|
|
77
|
+
return None
|
|
78
|
+
async with aiofiles.open(path, 'rb') as f:
|
|
79
|
+
return await f.read()
|
|
80
|
+
|
|
81
|
+
async def delete(self, key: str) -> None:
|
|
82
|
+
self._validate_key(key)
|
|
83
|
+
path = self.base_dir / key
|
|
84
|
+
path.unlink(missing_ok=True)
|
|
85
|
+
|
|
86
|
+
class MemoryBackend(PersistenceBackend):
|
|
87
|
+
"""In-memory backend for ephemeral executions."""
|
|
88
|
+
|
|
89
|
+
def __init__(self):
|
|
90
|
+
self._store: Dict[str, bytes] = {}
|
|
91
|
+
|
|
92
|
+
async def save(self, key: str, value: bytes) -> None:
|
|
93
|
+
self._store[key] = value
|
|
94
|
+
|
|
95
|
+
async def load(self, key: str) -> Optional[bytes]:
|
|
96
|
+
return self._store.get(key)
|
|
97
|
+
|
|
98
|
+
async def delete(self, key: str) -> None:
|
|
99
|
+
self._store.pop(key, None)
|
|
100
|
+
|
|
101
|
+
class CheckpointManager:
|
|
102
|
+
"""Manages saving and loading machine snapshots."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, backend: PersistenceBackend, execution_id: str):
|
|
105
|
+
self.backend = backend
|
|
106
|
+
self.execution_id = execution_id
|
|
107
|
+
|
|
108
|
+
def _snapshot_key(self, event: str, step: int) -> str:
|
|
109
|
+
"""Generate key for specific snapshot."""
|
|
110
|
+
return f"{self.execution_id}/step_{step:06d}_{event}.json"
|
|
111
|
+
|
|
112
|
+
def _latest_pointer_key(self) -> str:
|
|
113
|
+
"""Key that points to the latest snapshot."""
|
|
114
|
+
return f"{self.execution_id}/latest"
|
|
115
|
+
|
|
116
|
+
def _safe_serialize_value(self, value: Any, path: str, non_serializable: List[str]) -> Any:
|
|
117
|
+
"""Recursively serialize a value, converting non-JSON types to strings."""
|
|
118
|
+
if isinstance(value, dict):
|
|
119
|
+
result = {}
|
|
120
|
+
for k, v in value.items():
|
|
121
|
+
try:
|
|
122
|
+
json.dumps({k: v})
|
|
123
|
+
result[k] = v
|
|
124
|
+
except (TypeError, OverflowError):
|
|
125
|
+
result[k] = self._safe_serialize_value(v, f"{path}.{k}", non_serializable)
|
|
126
|
+
return result
|
|
127
|
+
elif isinstance(value, list):
|
|
128
|
+
result = []
|
|
129
|
+
for i, item in enumerate(value):
|
|
130
|
+
try:
|
|
131
|
+
json.dumps(item)
|
|
132
|
+
result.append(item)
|
|
133
|
+
except (TypeError, OverflowError):
|
|
134
|
+
result.append(self._safe_serialize_value(item, f"{path}[{i}]", non_serializable))
|
|
135
|
+
return result
|
|
136
|
+
else:
|
|
137
|
+
try:
|
|
138
|
+
json.dumps(value)
|
|
139
|
+
return value
|
|
140
|
+
except (TypeError, OverflowError):
|
|
141
|
+
original_type = type(value).__name__
|
|
142
|
+
non_serializable.append(f"{path} ({original_type})")
|
|
143
|
+
return str(value)
|
|
144
|
+
|
|
145
|
+
def _safe_serialize(self, data: Dict[str, Any]) -> str:
|
|
146
|
+
"""Safely serialize data to JSON, handling non-serializable objects."""
|
|
147
|
+
try:
|
|
148
|
+
return json.dumps(data)
|
|
149
|
+
except (TypeError, OverflowError):
|
|
150
|
+
# Identify and warn about specific non-serializable fields
|
|
151
|
+
safe_data = {}
|
|
152
|
+
non_serializable_fields: List[str] = []
|
|
153
|
+
|
|
154
|
+
for k, v in data.items():
|
|
155
|
+
if isinstance(v, dict):
|
|
156
|
+
# Recursively check nested dicts
|
|
157
|
+
try:
|
|
158
|
+
json.dumps(v)
|
|
159
|
+
safe_data[k] = v
|
|
160
|
+
except (TypeError, OverflowError):
|
|
161
|
+
safe_data[k] = self._safe_serialize_value(v, k, non_serializable_fields)
|
|
162
|
+
elif isinstance(v, list):
|
|
163
|
+
# Recursively check lists
|
|
164
|
+
try:
|
|
165
|
+
json.dumps(v)
|
|
166
|
+
safe_data[k] = v
|
|
167
|
+
except (TypeError, OverflowError):
|
|
168
|
+
safe_data[k] = self._safe_serialize_value(v, k, non_serializable_fields)
|
|
169
|
+
else:
|
|
170
|
+
try:
|
|
171
|
+
json.dumps({k: v})
|
|
172
|
+
safe_data[k] = v
|
|
173
|
+
except (TypeError, OverflowError):
|
|
174
|
+
original_type = type(v).__name__
|
|
175
|
+
safe_data[k] = str(v)
|
|
176
|
+
non_serializable_fields.append(f"{k} ({original_type})")
|
|
177
|
+
|
|
178
|
+
if non_serializable_fields:
|
|
179
|
+
logger.warning(
|
|
180
|
+
f"Context fields not JSON serializable, converted to strings: "
|
|
181
|
+
f"{', '.join(non_serializable_fields)}. "
|
|
182
|
+
f"These values will lose type information on restore."
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
return json.dumps(safe_data)
|
|
186
|
+
|
|
187
|
+
async def save_checkpoint(self, snapshot: MachineSnapshot) -> None:
|
|
188
|
+
"""Save a snapshot and update latest pointer."""
|
|
189
|
+
data = asdict(snapshot)
|
|
190
|
+
json_bytes = self._safe_serialize(data).encode('utf-8')
|
|
191
|
+
|
|
192
|
+
# Save the immutable snapshot
|
|
193
|
+
key = self._snapshot_key(snapshot.event or "unknown", snapshot.step)
|
|
194
|
+
await self.backend.save(key, json_bytes)
|
|
195
|
+
|
|
196
|
+
# Update pointer to this key
|
|
197
|
+
await self.backend.save(self._latest_pointer_key(), key.encode('utf-8'))
|
|
198
|
+
|
|
199
|
+
async def load_latest(self) -> Optional[MachineSnapshot]:
|
|
200
|
+
"""Load the latest snapshot."""
|
|
201
|
+
# Get pointer
|
|
202
|
+
ptr_bytes = await self.backend.load(self._latest_pointer_key())
|
|
203
|
+
if not ptr_bytes:
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
# Get snapshot
|
|
207
|
+
key = ptr_bytes.decode('utf-8')
|
|
208
|
+
data_bytes = await self.backend.load(key)
|
|
209
|
+
if not data_bytes:
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
data = json.loads(data_bytes.decode('utf-8'))
|
|
213
|
+
return MachineSnapshot(**data)
|
flatmachines/run.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FlatMachines CLI Runner.
|
|
3
|
+
|
|
4
|
+
Entry point for running machines via subprocess:
|
|
5
|
+
python -m flatmachines.run --config machine.yml --input '{"key": "value"}'
|
|
6
|
+
|
|
7
|
+
Used by SubprocessInvoker for fire-and-forget machine execution.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
logging.basicConfig(
|
|
18
|
+
level=logging.INFO,
|
|
19
|
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main():
|
|
26
|
+
parser = argparse.ArgumentParser(
|
|
27
|
+
description="Run a FlatMachine from the command line",
|
|
28
|
+
prog="python -m flatmachines.run"
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--config", "-c",
|
|
32
|
+
required=True,
|
|
33
|
+
help="Path to machine config file (YAML or JSON)"
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--input", "-i",
|
|
37
|
+
default="{}",
|
|
38
|
+
help="JSON string of input data"
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--execution-id", "-e",
|
|
42
|
+
help="Predetermined execution ID"
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--parent-id", "-p",
|
|
46
|
+
help="Parent execution ID for lineage tracking"
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--max-steps",
|
|
50
|
+
type=int,
|
|
51
|
+
default=1000,
|
|
52
|
+
help="Maximum execution steps (default: 1000)"
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--verbose", "-v",
|
|
56
|
+
action="store_true",
|
|
57
|
+
help="Enable verbose logging"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
args = parser.parse_args()
|
|
61
|
+
|
|
62
|
+
if args.verbose:
|
|
63
|
+
logging.getLogger().setLevel(logging.DEBUG)
|
|
64
|
+
|
|
65
|
+
# Parse input
|
|
66
|
+
try:
|
|
67
|
+
input_data = json.loads(args.input)
|
|
68
|
+
except json.JSONDecodeError as e:
|
|
69
|
+
logger.error(f"Invalid JSON input: {e}")
|
|
70
|
+
sys.exit(1)
|
|
71
|
+
|
|
72
|
+
# Validate config path
|
|
73
|
+
config_path = Path(args.config)
|
|
74
|
+
if not config_path.exists():
|
|
75
|
+
logger.error(f"Config file not found: {config_path}")
|
|
76
|
+
sys.exit(1)
|
|
77
|
+
|
|
78
|
+
# Import here to avoid circular imports
|
|
79
|
+
from .flatmachine import FlatMachine
|
|
80
|
+
|
|
81
|
+
# Build machine with optional execution IDs
|
|
82
|
+
machine_kwargs = {
|
|
83
|
+
"config_file": str(config_path),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if args.execution_id:
|
|
87
|
+
machine_kwargs["_execution_id"] = args.execution_id
|
|
88
|
+
if args.parent_id:
|
|
89
|
+
machine_kwargs["_parent_execution_id"] = args.parent_id
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
machine = FlatMachine(**machine_kwargs)
|
|
93
|
+
|
|
94
|
+
# Run the machine
|
|
95
|
+
result = asyncio.run(
|
|
96
|
+
machine.execute(
|
|
97
|
+
input=input_data,
|
|
98
|
+
max_steps=args.max_steps
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Output result as JSON
|
|
103
|
+
print(json.dumps(result, indent=2, default=str))
|
|
104
|
+
|
|
105
|
+
# Log stats
|
|
106
|
+
logger.info(
|
|
107
|
+
f"Execution complete: {machine.total_api_calls} API calls, "
|
|
108
|
+
f"${machine.total_cost:.4f} cost"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
except Exception as e:
|
|
112
|
+
logger.exception(f"Execution failed: {e}")
|
|
113
|
+
sys.exit(1)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
main()
|
flatmachines/utils.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Utility functions for flatmachines."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from types import SimpleNamespace
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from .monitoring import get_logger
|
|
8
|
+
|
|
9
|
+
logger = get_logger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def check_spec_version(config_version: Optional[str], sdk_version: str) -> str:
|
|
13
|
+
"""
|
|
14
|
+
Check spec version compatibility and warn if mismatched.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
config_version: Version from config file (may be None)
|
|
18
|
+
sdk_version: Current SDK version (__version__)
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
The effective spec version (config_version or sdk_version as default)
|
|
22
|
+
"""
|
|
23
|
+
effective_version = config_version or sdk_version
|
|
24
|
+
sdk_major_minor = '.'.join(sdk_version.split('.')[:2])
|
|
25
|
+
config_major_minor = '.'.join(effective_version.split('.')[:2])
|
|
26
|
+
|
|
27
|
+
if config_major_minor != sdk_major_minor:
|
|
28
|
+
logger.warning(
|
|
29
|
+
f"Config version {effective_version} may not be fully supported. "
|
|
30
|
+
f"Current SDK version is {sdk_version}."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
return effective_version
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def strip_markdown_json(content: str) -> str:
|
|
37
|
+
"""
|
|
38
|
+
Extract JSON from potentially wrapped response content.
|
|
39
|
+
|
|
40
|
+
LLMs sometimes wrap JSON responses in markdown code blocks like:
|
|
41
|
+
```json
|
|
42
|
+
{"key": "value"}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Or include explanatory text before/after the JSON:
|
|
46
|
+
"Here is the result:
|
|
47
|
+
```json
|
|
48
|
+
{"key": "value"}
|
|
49
|
+
```"
|
|
50
|
+
|
|
51
|
+
This function extracts the JSON so json.loads() can parse it.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
content: Raw string that may contain markdown-wrapped JSON
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Extracted JSON string
|
|
58
|
+
"""
|
|
59
|
+
if not content:
|
|
60
|
+
return content
|
|
61
|
+
|
|
62
|
+
text = content.strip()
|
|
63
|
+
|
|
64
|
+
# First, try to find JSON in a markdown code fence (anywhere in content)
|
|
65
|
+
fence_pattern = r'```(?:json|JSON)?\s*\n?([\s\S]*?)\n?```'
|
|
66
|
+
match = re.search(fence_pattern, text)
|
|
67
|
+
if match:
|
|
68
|
+
return match.group(1).strip()
|
|
69
|
+
|
|
70
|
+
# If no fence, try to find a raw JSON object or array
|
|
71
|
+
json_pattern = r'(\{[\s\S]*\}|\[[\s\S]*\])'
|
|
72
|
+
match = re.search(json_pattern, text)
|
|
73
|
+
if match:
|
|
74
|
+
return match.group(1)
|
|
75
|
+
|
|
76
|
+
return text
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _get_attr(obj: Any, key: str, default: Any = None) -> Any:
|
|
80
|
+
if obj is None:
|
|
81
|
+
return default
|
|
82
|
+
if hasattr(obj, key):
|
|
83
|
+
return getattr(obj, key)
|
|
84
|
+
if isinstance(obj, dict):
|
|
85
|
+
return obj.get(key, default)
|
|
86
|
+
return default
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _coerce_usage(usage: Any) -> Any:
|
|
90
|
+
if usage is None:
|
|
91
|
+
return None
|
|
92
|
+
if isinstance(usage, dict):
|
|
93
|
+
return SimpleNamespace(**usage)
|
|
94
|
+
return usage
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def consume_litellm_stream(stream: Any) -> Any:
|
|
98
|
+
content_parts: List[str] = []
|
|
99
|
+
tool_calls: Dict[int, Dict[str, Any]] = {}
|
|
100
|
+
usage_data: Any = None
|
|
101
|
+
finish_reason: Optional[str] = None
|
|
102
|
+
|
|
103
|
+
async for chunk in stream:
|
|
104
|
+
if chunk is None:
|
|
105
|
+
continue
|
|
106
|
+
usage = _get_attr(chunk, "usage")
|
|
107
|
+
if usage:
|
|
108
|
+
usage_data = usage
|
|
109
|
+
|
|
110
|
+
choices = _get_attr(chunk, "choices")
|
|
111
|
+
if not choices:
|
|
112
|
+
continue
|
|
113
|
+
choice0 = choices[0]
|
|
114
|
+
finish = _get_attr(choice0, "finish_reason")
|
|
115
|
+
if finish:
|
|
116
|
+
finish_reason = finish
|
|
117
|
+
|
|
118
|
+
delta = _get_attr(choice0, "delta")
|
|
119
|
+
if not delta:
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
content_piece = _get_attr(delta, "content")
|
|
123
|
+
if content_piece:
|
|
124
|
+
content_parts.append(content_piece)
|
|
125
|
+
|
|
126
|
+
delta_tool_calls = _get_attr(delta, "tool_calls")
|
|
127
|
+
if delta_tool_calls:
|
|
128
|
+
for tc in delta_tool_calls:
|
|
129
|
+
index = _get_attr(tc, "index", 0)
|
|
130
|
+
entry = tool_calls.setdefault(index, {"id": None, "name": None, "arguments": []})
|
|
131
|
+
tc_id = _get_attr(tc, "id")
|
|
132
|
+
if tc_id:
|
|
133
|
+
entry["id"] = tc_id
|
|
134
|
+
function = _get_attr(tc, "function")
|
|
135
|
+
if function:
|
|
136
|
+
name = _get_attr(function, "name")
|
|
137
|
+
if name:
|
|
138
|
+
entry["name"] = name
|
|
139
|
+
arguments = _get_attr(function, "arguments")
|
|
140
|
+
if arguments:
|
|
141
|
+
entry["arguments"].append(arguments)
|
|
142
|
+
|
|
143
|
+
content = "".join(content_parts)
|
|
144
|
+
message_fields: Dict[str, Any] = {"content": content}
|
|
145
|
+
if tool_calls:
|
|
146
|
+
tool_call_objs = []
|
|
147
|
+
for index in sorted(tool_calls):
|
|
148
|
+
entry = tool_calls[index]
|
|
149
|
+
tool_call_objs.append(SimpleNamespace(
|
|
150
|
+
id=entry["id"],
|
|
151
|
+
function=SimpleNamespace(
|
|
152
|
+
name=entry["name"],
|
|
153
|
+
arguments="".join(entry["arguments"]) if entry["arguments"] else ""
|
|
154
|
+
)
|
|
155
|
+
))
|
|
156
|
+
message_fields["tool_calls"] = tool_call_objs
|
|
157
|
+
|
|
158
|
+
message = SimpleNamespace(**message_fields)
|
|
159
|
+
choice = SimpleNamespace(message=message)
|
|
160
|
+
if finish_reason is not None:
|
|
161
|
+
choice.finish_reason = finish_reason
|
|
162
|
+
|
|
163
|
+
return SimpleNamespace(
|
|
164
|
+
choices=[choice],
|
|
165
|
+
usage=_coerce_usage(usage_data)
|
|
166
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Schema validation for flatmachine configurations.
|
|
3
|
+
|
|
4
|
+
Uses JSON Schema validation against the bundled schema.
|
|
5
|
+
Validation errors are warnings by default to avoid breaking user configs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import warnings
|
|
10
|
+
from importlib.resources import files
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
_ASSETS = files("flatmachines.assets")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ValidationWarning(UserWarning):
|
|
17
|
+
"""Warning for schema validation issues."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_schema(filename: str) -> Optional[Dict[str, Any]]:
|
|
22
|
+
try:
|
|
23
|
+
content = (_ASSETS / filename).read_text()
|
|
24
|
+
return json.loads(content)
|
|
25
|
+
except FileNotFoundError:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _validate_with_jsonschema(config: Dict[str, Any], schema: Dict[str, Any]) -> List[str]:
|
|
30
|
+
try:
|
|
31
|
+
import jsonschema
|
|
32
|
+
except ImportError:
|
|
33
|
+
return []
|
|
34
|
+
|
|
35
|
+
errors: List[str] = []
|
|
36
|
+
validator = jsonschema.Draft7Validator(schema)
|
|
37
|
+
for error in validator.iter_errors(config):
|
|
38
|
+
path = ".".join(str(p) for p in error.absolute_path) or "(root)"
|
|
39
|
+
errors.append(f"{path}: {error.message}")
|
|
40
|
+
return errors
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validate_flatmachine_config(
|
|
44
|
+
config: Dict[str, Any],
|
|
45
|
+
warn: bool = True,
|
|
46
|
+
strict: bool = False,
|
|
47
|
+
) -> List[str]:
|
|
48
|
+
"""Validate a flatmachine configuration against the schema."""
|
|
49
|
+
schema = _load_schema("flatmachine.schema.json")
|
|
50
|
+
if schema is None:
|
|
51
|
+
return []
|
|
52
|
+
|
|
53
|
+
errors = _validate_with_jsonschema(config, schema)
|
|
54
|
+
|
|
55
|
+
if errors:
|
|
56
|
+
if strict:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
"Flatmachine config validation failed:\n"
|
|
59
|
+
+ "\n".join(f" - {e}" for e in errors)
|
|
60
|
+
)
|
|
61
|
+
if warn:
|
|
62
|
+
warnings.warn(
|
|
63
|
+
"Flatmachine config has validation issues:\n"
|
|
64
|
+
+ "\n".join(f" - {e}" for e in errors),
|
|
65
|
+
ValidationWarning,
|
|
66
|
+
stacklevel=3,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return errors
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_flatmachine_schema() -> Optional[Dict[str, Any]]:
|
|
73
|
+
"""Get the bundled flatmachine JSON schema."""
|
|
74
|
+
return _load_schema("flatmachine.schema.json")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_asset(filename: str) -> str:
|
|
78
|
+
"""Get the contents of a bundled asset file."""
|
|
79
|
+
return (_ASSETS / filename).read_text()
|