AbstractRuntime 0.2.0__py3-none-any.whl → 0.4.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 (36) hide show
  1. abstractruntime/__init__.py +7 -2
  2. abstractruntime/core/config.py +14 -1
  3. abstractruntime/core/event_keys.py +62 -0
  4. abstractruntime/core/models.py +12 -1
  5. abstractruntime/core/runtime.py +2444 -14
  6. abstractruntime/core/vars.py +95 -0
  7. abstractruntime/evidence/__init__.py +10 -0
  8. abstractruntime/evidence/recorder.py +325 -0
  9. abstractruntime/integrations/abstractcore/__init__.py +3 -0
  10. abstractruntime/integrations/abstractcore/constants.py +19 -0
  11. abstractruntime/integrations/abstractcore/default_tools.py +134 -0
  12. abstractruntime/integrations/abstractcore/effect_handlers.py +255 -6
  13. abstractruntime/integrations/abstractcore/factory.py +95 -10
  14. abstractruntime/integrations/abstractcore/llm_client.py +456 -52
  15. abstractruntime/integrations/abstractcore/mcp_worker.py +586 -0
  16. abstractruntime/integrations/abstractcore/observability.py +80 -0
  17. abstractruntime/integrations/abstractcore/summarizer.py +154 -0
  18. abstractruntime/integrations/abstractcore/tool_executor.py +481 -24
  19. abstractruntime/memory/__init__.py +21 -0
  20. abstractruntime/memory/active_context.py +746 -0
  21. abstractruntime/memory/active_memory.py +452 -0
  22. abstractruntime/memory/compaction.py +105 -0
  23. abstractruntime/rendering/__init__.py +17 -0
  24. abstractruntime/rendering/agent_trace_report.py +256 -0
  25. abstractruntime/rendering/json_stringify.py +136 -0
  26. abstractruntime/scheduler/scheduler.py +93 -2
  27. abstractruntime/storage/__init__.py +3 -1
  28. abstractruntime/storage/artifacts.py +20 -5
  29. abstractruntime/storage/json_files.py +15 -2
  30. abstractruntime/storage/observable.py +99 -0
  31. {abstractruntime-0.2.0.dist-info → abstractruntime-0.4.0.dist-info}/METADATA +5 -1
  32. abstractruntime-0.4.0.dist-info/RECORD +49 -0
  33. abstractruntime-0.4.0.dist-info/entry_points.txt +2 -0
  34. abstractruntime-0.2.0.dist-info/RECORD +0 -32
  35. {abstractruntime-0.2.0.dist-info → abstractruntime-0.4.0.dist-info}/WHEEL +0 -0
  36. {abstractruntime-0.2.0.dist-info → abstractruntime-0.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -11,7 +11,8 @@ They are designed to keep `RunState.vars` JSON-safe.
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
- from typing import Any, Dict, Optional
14
+ import json
15
+ from typing import Any, Dict, Optional, Set, Tuple, Type
15
16
 
16
17
  from ...core.models import Effect, EffectType, RunState, WaitReason, WaitState
17
18
  from ...core.runtime import EffectOutcome, EffectHandler
@@ -22,8 +23,96 @@ from .logging import get_logger
22
23
  logger = get_logger(__name__)
23
24
 
24
25
 
26
+ def _jsonable(value: Any) -> Any:
27
+ """Best-effort conversion to JSON-safe objects.
28
+
29
+ Runtime traces and effect outcomes are persisted in RunState.vars and must remain JSON-safe.
30
+ """
31
+ if value is None:
32
+ return None
33
+ if isinstance(value, (str, int, float, bool)):
34
+ return value
35
+ if isinstance(value, dict):
36
+ return {str(k): _jsonable(v) for k, v in value.items()}
37
+ if isinstance(value, list):
38
+ return [_jsonable(v) for v in value]
39
+ try:
40
+ json.dumps(value)
41
+ return value
42
+ except Exception:
43
+ return str(value)
44
+
45
+
46
+ def _pydantic_model_from_json_schema(schema: Dict[str, Any], *, name: str) -> Type[Any]:
47
+ """Best-effort conversion from a JSON schema dict to a Pydantic model.
48
+
49
+ This exists so structured output requests can remain JSON-safe in durable
50
+ effect payloads (we persist the schema, not the Python class).
51
+ """
52
+ try:
53
+ from pydantic import BaseModel, create_model
54
+ except Exception as e: # pragma: no cover
55
+ raise RuntimeError(f"Pydantic is required for structured outputs: {e}")
56
+
57
+ def _python_type(sub_schema: Any, *, nested_name: str) -> Any:
58
+ if not isinstance(sub_schema, dict):
59
+ return Any
60
+ t = sub_schema.get("type")
61
+ if t == "string":
62
+ return str
63
+ if t == "integer":
64
+ return int
65
+ if t == "number":
66
+ return float
67
+ if t == "boolean":
68
+ return bool
69
+ if t == "array":
70
+ items = sub_schema.get("items")
71
+ return list[_python_type(items, nested_name=f"{nested_name}Item")] # type: ignore[index]
72
+ if t == "object":
73
+ props = sub_schema.get("properties")
74
+ if isinstance(props, dict) and props:
75
+ return _model(sub_schema, name=nested_name)
76
+ return Dict[str, Any]
77
+ return Any
78
+
79
+ def _model(obj_schema: Dict[str, Any], *, name: str) -> Type[BaseModel]:
80
+ if obj_schema.get("type") != "object":
81
+ raise ValueError("response_schema must be a JSON schema object")
82
+ props = obj_schema.get("properties")
83
+ if not isinstance(props, dict) or not props:
84
+ raise ValueError("response_schema must define properties")
85
+ required_raw = obj_schema.get("required")
86
+ required: Set[str] = set()
87
+ if isinstance(required_raw, list):
88
+ required = {str(x) for x in required_raw if isinstance(x, str)}
89
+
90
+ fields: Dict[str, Tuple[Any, Any]] = {}
91
+ for prop_name, prop_schema in props.items():
92
+ if not isinstance(prop_name, str) or not prop_name.strip():
93
+ continue
94
+ # Keep things simple: only support identifier-like names to avoid aliasing issues.
95
+ if not prop_name.isidentifier():
96
+ raise ValueError(
97
+ f"Invalid property name '{prop_name}'. Use identifier-style names (letters, digits, underscore)."
98
+ )
99
+ t = _python_type(prop_schema, nested_name=f"{name}_{prop_name}")
100
+ if prop_name in required:
101
+ fields[prop_name] = (t, ...)
102
+ else:
103
+ fields[prop_name] = (Optional[t], None)
104
+
105
+ return create_model(name, **fields) # type: ignore[call-arg]
106
+
107
+ return _model(schema, name=name)
108
+
109
+
25
110
  def _trace_context(run: RunState) -> Dict[str, str]:
26
- ctx: Dict[str, str] = {"run_id": run.run_id}
111
+ ctx: Dict[str, str] = {
112
+ "run_id": run.run_id,
113
+ "workflow_id": str(run.workflow_id),
114
+ "node_id": str(run.current_node),
115
+ }
27
116
  if run.actor_id:
28
117
  ctx["actor_id"] = str(run.actor_id)
29
118
  session_id = getattr(run, "session_id", None)
@@ -40,7 +129,11 @@ def make_llm_call_handler(*, llm: AbstractCoreLLMClient) -> EffectHandler:
40
129
  prompt = payload.get("prompt")
41
130
  messages = payload.get("messages")
42
131
  system_prompt = payload.get("system_prompt")
132
+ provider = payload.get("provider")
133
+ model = payload.get("model")
43
134
  tools = payload.get("tools")
135
+ response_schema = payload.get("response_schema")
136
+ response_schema_name = payload.get("response_schema_name")
44
137
  raw_params = payload.get("params")
45
138
  params = dict(raw_params) if isinstance(raw_params, dict) else {}
46
139
 
@@ -51,10 +144,38 @@ def make_llm_call_handler(*, llm: AbstractCoreLLMClient) -> EffectHandler:
51
144
  trace_metadata.update(_trace_context(run))
52
145
  params["trace_metadata"] = trace_metadata
53
146
 
147
+ # Support per-effect routing: allow the payload to override provider/model.
148
+ # These reserved keys are consumed by MultiLocalAbstractCoreLLMClient and
149
+ # ignored by LocalAbstractCoreLLMClient.
150
+ if isinstance(provider, str) and provider.strip():
151
+ params["_provider"] = provider.strip()
152
+ if isinstance(model, str) and model.strip():
153
+ params["_model"] = model.strip()
154
+
54
155
  if not prompt and not messages:
55
156
  return EffectOutcome.failed("llm_call requires payload.prompt or payload.messages")
56
157
 
57
158
  try:
159
+ if isinstance(response_schema, dict) and response_schema:
160
+ model_name = (
161
+ str(response_schema_name).strip()
162
+ if isinstance(response_schema_name, str) and response_schema_name.strip()
163
+ else "StructuredOutput"
164
+ )
165
+ params["response_model"] = _pydantic_model_from_json_schema(response_schema, name=model_name)
166
+
167
+ runtime_observability = {
168
+ "llm_generate_kwargs": _jsonable(
169
+ {
170
+ "prompt": str(prompt or ""),
171
+ "messages": messages,
172
+ "system_prompt": system_prompt,
173
+ "tools": tools,
174
+ "params": params,
175
+ }
176
+ ),
177
+ }
178
+
58
179
  result = llm.generate(
59
180
  prompt=str(prompt or ""),
60
181
  messages=messages,
@@ -62,6 +183,16 @@ def make_llm_call_handler(*, llm: AbstractCoreLLMClient) -> EffectHandler:
62
183
  tools=tools,
63
184
  params=params,
64
185
  )
186
+ if isinstance(result, dict):
187
+ meta = result.get("metadata")
188
+ if not isinstance(meta, dict):
189
+ meta = {}
190
+ result["metadata"] = meta
191
+ existing = meta.get("_runtime_observability")
192
+ if not isinstance(existing, dict):
193
+ existing = {}
194
+ meta["_runtime_observability"] = existing
195
+ existing.update(runtime_observability)
65
196
  return EffectOutcome.completed(result=result)
66
197
  except Exception as e:
67
198
  logger.error("LLM_CALL failed", error=str(e))
@@ -81,6 +212,11 @@ def make_tool_calls_handler(*, tools: Optional[ToolExecutor] = None) -> EffectHa
81
212
  tool_calls = payload.get("tool_calls")
82
213
  if not isinstance(tool_calls, list):
83
214
  return EffectOutcome.failed("tool_calls requires payload.tool_calls (list)")
215
+ allowed_tools_raw = payload.get("allowed_tools")
216
+ allowlist_enabled = isinstance(allowed_tools_raw, list)
217
+ allowed_tools: Set[str] = set()
218
+ if allowlist_enabled:
219
+ allowed_tools = {str(t) for t in allowed_tools_raw if isinstance(t, str) and t.strip()}
84
220
 
85
221
  if tools is None:
86
222
  return EffectOutcome.failed(
@@ -88,8 +224,70 @@ def make_tool_calls_handler(*, tools: Optional[ToolExecutor] = None) -> EffectHa
88
224
  "MappingToolExecutor/AbstractCoreToolExecutor/PassthroughToolExecutor."
89
225
  )
90
226
 
227
+ original_call_count = len(tool_calls)
228
+
229
+ # Always block non-dict tool call entries: passthrough hosts expect dicts and may crash otherwise.
230
+ blocked_by_index: Dict[int, Dict[str, Any]] = {}
231
+ filtered_tool_calls: list[Dict[str, Any]] = []
232
+
233
+ # For evidence and deterministic resume merging, keep a positional tool call list aligned to the
234
+ # *original* tool call order. Blocked entries are represented as empty-args stubs.
235
+ tool_calls_for_evidence: list[Dict[str, Any]] = []
236
+
237
+ for idx, tc in enumerate(tool_calls):
238
+ if not isinstance(tc, dict):
239
+ blocked_by_index[idx] = {
240
+ "call_id": "",
241
+ "name": "",
242
+ "success": False,
243
+ "output": None,
244
+ "error": "Invalid tool call (expected an object)",
245
+ }
246
+ tool_calls_for_evidence.append({})
247
+ continue
248
+
249
+ name_raw = tc.get("name")
250
+ name = name_raw.strip() if isinstance(name_raw, str) else ""
251
+ call_id = str(tc.get("call_id") or "")
252
+
253
+ if allowlist_enabled:
254
+ if not name:
255
+ blocked_by_index[idx] = {
256
+ "call_id": call_id,
257
+ "name": "",
258
+ "success": False,
259
+ "output": None,
260
+ "error": "Tool call missing a valid name",
261
+ }
262
+ tool_calls_for_evidence.append({"call_id": call_id, "name": "", "arguments": {}})
263
+ continue
264
+ if name not in allowed_tools:
265
+ blocked_by_index[idx] = {
266
+ "call_id": call_id,
267
+ "name": name,
268
+ "success": False,
269
+ "output": None,
270
+ "error": f"Tool '{name}' is not allowed for this node",
271
+ }
272
+ # Do not leak arguments for disallowed tools into the durable wait payload.
273
+ tool_calls_for_evidence.append({"call_id": call_id, "name": name, "arguments": {}})
274
+ continue
275
+
276
+ # Allowed (or allowlist disabled): include for execution and keep full args for evidence.
277
+ filtered_tool_calls.append(tc)
278
+ tool_calls_for_evidence.append(tc)
279
+
280
+ # If everything was blocked, complete immediately with blocked results (no waiting/execution).
281
+ if not filtered_tool_calls and blocked_by_index:
282
+ return EffectOutcome.completed(
283
+ result={
284
+ "mode": "executed",
285
+ "results": [blocked_by_index[i] for i in sorted(blocked_by_index.keys())],
286
+ }
287
+ )
288
+
91
289
  try:
92
- result = tools.execute(tool_calls=tool_calls)
290
+ result = tools.execute(tool_calls=filtered_tool_calls)
93
291
  except Exception as e:
94
292
  logger.error("TOOL_CALLS execution failed", error=str(e))
95
293
  return EffectOutcome.failed(str(e))
@@ -97,16 +295,67 @@ def make_tool_calls_handler(*, tools: Optional[ToolExecutor] = None) -> EffectHa
97
295
  mode = result.get("mode")
98
296
  if mode and mode != "executed":
99
297
  # Passthrough/untrusted mode: pause until an external host resumes with tool results.
100
- wait_key = payload.get("wait_key") or f"tool_calls:{run.run_id}:{run.current_node}"
298
+ #
299
+ # Correctness/security: persist only allowlist-safe tool calls in the wait payload.
300
+ wait_key = payload.get("wait_key") or result.get("wait_key") or f"tool_calls:{run.run_id}:{run.current_node}"
301
+ raw_wait_reason = result.get("wait_reason")
302
+ wait_reason = WaitReason.EVENT
303
+ if isinstance(raw_wait_reason, str) and raw_wait_reason.strip():
304
+ try:
305
+ wait_reason = WaitReason(raw_wait_reason.strip())
306
+ except ValueError:
307
+ wait_reason = WaitReason.EVENT
308
+ elif str(mode).strip().lower() == "delegated":
309
+ wait_reason = WaitReason.JOB
310
+
311
+ tool_calls_for_wait = result.get("tool_calls")
312
+ if not isinstance(tool_calls_for_wait, list):
313
+ tool_calls_for_wait = filtered_tool_calls
314
+
315
+ details: Dict[str, Any] = {"mode": mode, "tool_calls": _jsonable(tool_calls_for_wait)}
316
+ executor_details = result.get("details")
317
+ if isinstance(executor_details, dict) and executor_details:
318
+ # Avoid collisions with our reserved keys.
319
+ details["executor"] = _jsonable(executor_details)
320
+ if blocked_by_index:
321
+ details["original_call_count"] = original_call_count
322
+ details["blocked_by_index"] = {str(k): _jsonable(v) for k, v in blocked_by_index.items()}
323
+ details["tool_calls_for_evidence"] = _jsonable(tool_calls_for_evidence)
324
+
101
325
  wait = WaitState(
102
- reason=WaitReason.EVENT,
326
+ reason=wait_reason,
103
327
  wait_key=str(wait_key),
104
328
  resume_to_node=payload.get("resume_to_node") or default_next_node,
105
329
  result_key=effect.result_key,
106
- details={"mode": mode, "tool_calls": tool_calls},
330
+ details=details,
107
331
  )
108
332
  return EffectOutcome.waiting(wait)
109
333
 
334
+ if blocked_by_index:
335
+ existing_results = result.get("results")
336
+ if isinstance(existing_results, list):
337
+ merged_results: list[Any] = []
338
+ executed_iter = iter(existing_results)
339
+ for idx in range(len(tool_calls)):
340
+ blocked = blocked_by_index.get(idx)
341
+ if blocked is not None:
342
+ merged_results.append(blocked)
343
+ continue
344
+ try:
345
+ merged_results.append(next(executed_iter))
346
+ except StopIteration:
347
+ merged_results.append(
348
+ {
349
+ "call_id": "",
350
+ "name": "",
351
+ "success": False,
352
+ "output": None,
353
+ "error": "Missing tool result",
354
+ }
355
+ )
356
+ result = dict(result)
357
+ result["results"] = merged_results
358
+
110
359
  return EffectOutcome.completed(result=result)
111
360
 
112
361
  return _handler
@@ -20,10 +20,14 @@ from ...core.runtime import Runtime
20
20
  from ...storage.in_memory import InMemoryLedgerStore, InMemoryRunStore
21
21
  from ...storage.json_files import JsonFileRunStore, JsonlLedgerStore
22
22
  from ...storage.base import LedgerStore, RunStore
23
+ from ...storage.artifacts import FileArtifactStore, InMemoryArtifactStore, ArtifactStore
24
+ from ...storage.observable import ObservableLedgerStore, ObservableLedgerStoreProtocol
23
25
 
24
26
  from .effect_handlers import build_effect_handlers
25
- from .llm_client import LocalAbstractCoreLLMClient, RemoteAbstractCoreLLMClient
27
+ from .llm_client import MultiLocalAbstractCoreLLMClient, RemoteAbstractCoreLLMClient
26
28
  from .tool_executor import AbstractCoreToolExecutor, PassthroughToolExecutor, ToolExecutor
29
+ from .summarizer import AbstractCoreChatSummarizer
30
+ from .constants import DEFAULT_LLM_TIMEOUT_S, DEFAULT_TOOL_TIMEOUT_S
27
31
 
28
32
 
29
33
  def _default_in_memory_stores() -> tuple[RunStore, LedgerStore]:
@@ -35,6 +39,18 @@ def _default_file_stores(*, base_dir: str | Path) -> tuple[RunStore, LedgerStore
35
39
  base.mkdir(parents=True, exist_ok=True)
36
40
  return JsonFileRunStore(base), JsonlLedgerStore(base)
37
41
 
42
+ def _ensure_observable_ledger(ledger_store: LedgerStore) -> LedgerStore:
43
+ """Wrap a LedgerStore so Runtime.subscribe_ledger() is available (in-process).
44
+
45
+ Why:
46
+ - Real-time UI/UX often needs "step started" signals *before* a blocking effect
47
+ (LLM/tool HTTP) returns.
48
+ - The runtime kernel stays transport-agnostic; this is an optional decorator.
49
+ """
50
+ if isinstance(ledger_store, ObservableLedgerStoreProtocol):
51
+ return ledger_store
52
+ return ObservableLedgerStore(ledger_store)
53
+
38
54
 
39
55
  def create_local_runtime(
40
56
  *,
@@ -44,9 +60,11 @@ def create_local_runtime(
44
60
  run_store: Optional[RunStore] = None,
45
61
  ledger_store: Optional[LedgerStore] = None,
46
62
  tool_executor: Optional[ToolExecutor] = None,
63
+ tool_timeout_s: float = DEFAULT_TOOL_TIMEOUT_S,
47
64
  context: Optional[Any] = None,
48
65
  effect_policy: Optional[Any] = None,
49
66
  config: Optional[RuntimeConfig] = None,
67
+ artifact_store: Optional[ArtifactStore] = None,
50
68
  ) -> Runtime:
51
69
  """Create a runtime with local LLM execution via AbstractCore.
52
70
 
@@ -69,19 +87,49 @@ def create_local_runtime(
69
87
  """
70
88
  if run_store is None or ledger_store is None:
71
89
  run_store, ledger_store = _default_in_memory_stores()
90
+ ledger_store = _ensure_observable_ledger(ledger_store)
72
91
 
73
- llm_client = LocalAbstractCoreLLMClient(provider=provider, model=model, llm_kwargs=llm_kwargs)
74
- tools = tool_executor or AbstractCoreToolExecutor()
92
+ if artifact_store is None:
93
+ artifact_store = InMemoryArtifactStore()
94
+
95
+ # Runtime authority: default LLM timeout for orchestrated workflows.
96
+ #
97
+ # We set this here (in the runtime layer) rather than relying on AbstractCore global config,
98
+ # so workflow behavior is consistent and controlled by the orchestrator.
99
+ effective_llm_kwargs: Dict[str, Any] = dict(llm_kwargs or {})
100
+ effective_llm_kwargs.setdefault("timeout", DEFAULT_LLM_TIMEOUT_S)
101
+
102
+ llm_client = MultiLocalAbstractCoreLLMClient(provider=provider, model=model, llm_kwargs=effective_llm_kwargs)
103
+ tools = tool_executor or AbstractCoreToolExecutor(timeout_s=tool_timeout_s)
104
+ # Orchestrator policy: enforce tool execution timeout at the runtime layer.
105
+ try:
106
+ setter = getattr(tools, "set_timeout_s", None)
107
+ if callable(setter):
108
+ setter(tool_timeout_s)
109
+ except Exception:
110
+ pass
75
111
  handlers = build_effect_handlers(llm=llm_client, tools=tools)
76
112
 
77
113
  # Query model capabilities and merge into config
78
114
  capabilities = llm_client.get_model_capabilities()
79
115
  if config is None:
80
- config = RuntimeConfig(model_capabilities=capabilities)
116
+ config = RuntimeConfig(
117
+ provider=str(provider).strip() if isinstance(provider, str) and str(provider).strip() else None,
118
+ model=str(model).strip() if isinstance(model, str) and str(model).strip() else None,
119
+ model_capabilities=capabilities,
120
+ )
81
121
  else:
82
122
  # Merge capabilities into provided config
83
123
  config = config.with_capabilities(capabilities)
84
124
 
125
+ # Create chat summarizer with token limits from config
126
+ # This enables adaptive chunking during MEMORY_COMPACT
127
+ summarizer = AbstractCoreChatSummarizer(
128
+ llm=llm_client._llm, # Use the underlying AbstractCore LLM instance
129
+ max_tokens=config.max_tokens if config.max_tokens is not None else -1,
130
+ max_output_tokens=config.max_output_tokens if config.max_output_tokens is not None else -1,
131
+ )
132
+
85
133
  return Runtime(
86
134
  run_store=run_store,
87
135
  ledger_store=ledger_store,
@@ -89,6 +137,8 @@ def create_local_runtime(
89
137
  context=context,
90
138
  effect_policy=effect_policy,
91
139
  config=config,
140
+ artifact_store=artifact_store,
141
+ chat_summarizer=summarizer,
92
142
  )
93
143
 
94
144
 
@@ -97,14 +147,19 @@ def create_remote_runtime(
97
147
  server_base_url: str,
98
148
  model: str,
99
149
  headers: Optional[Dict[str, str]] = None,
100
- timeout_s: float = 60.0,
150
+ timeout_s: float = DEFAULT_LLM_TIMEOUT_S,
101
151
  run_store: Optional[RunStore] = None,
102
152
  ledger_store: Optional[LedgerStore] = None,
103
153
  tool_executor: Optional[ToolExecutor] = None,
104
154
  context: Optional[Any] = None,
155
+ artifact_store: Optional[ArtifactStore] = None,
105
156
  ) -> Runtime:
106
157
  if run_store is None or ledger_store is None:
107
158
  run_store, ledger_store = _default_in_memory_stores()
159
+ ledger_store = _ensure_observable_ledger(ledger_store)
160
+
161
+ if artifact_store is None:
162
+ artifact_store = InMemoryArtifactStore()
108
163
 
109
164
  llm_client = RemoteAbstractCoreLLMClient(
110
165
  server_base_url=server_base_url,
@@ -115,7 +170,13 @@ def create_remote_runtime(
115
170
  tools = tool_executor or PassthroughToolExecutor()
116
171
  handlers = build_effect_handlers(llm=llm_client, tools=tools)
117
172
 
118
- return Runtime(run_store=run_store, ledger_store=ledger_store, effect_handlers=handlers, context=context)
173
+ return Runtime(
174
+ run_store=run_store,
175
+ ledger_store=ledger_store,
176
+ effect_handlers=handlers,
177
+ context=context,
178
+ artifact_store=artifact_store,
179
+ )
119
180
 
120
181
 
121
182
  def create_hybrid_runtime(
@@ -123,15 +184,21 @@ def create_hybrid_runtime(
123
184
  server_base_url: str,
124
185
  model: str,
125
186
  headers: Optional[Dict[str, str]] = None,
126
- timeout_s: float = 60.0,
187
+ timeout_s: float = DEFAULT_LLM_TIMEOUT_S,
188
+ tool_timeout_s: float = DEFAULT_TOOL_TIMEOUT_S,
127
189
  run_store: Optional[RunStore] = None,
128
190
  ledger_store: Optional[LedgerStore] = None,
129
191
  context: Optional[Any] = None,
192
+ artifact_store: Optional[ArtifactStore] = None,
130
193
  ) -> Runtime:
131
194
  """Remote LLM via AbstractCore server, local tool execution."""
132
195
 
133
196
  if run_store is None or ledger_store is None:
134
197
  run_store, ledger_store = _default_in_memory_stores()
198
+ ledger_store = _ensure_observable_ledger(ledger_store)
199
+
200
+ if artifact_store is None:
201
+ artifact_store = InMemoryArtifactStore()
135
202
 
136
203
  llm_client = RemoteAbstractCoreLLMClient(
137
204
  server_base_url=server_base_url,
@@ -139,10 +206,22 @@ def create_hybrid_runtime(
139
206
  headers=headers,
140
207
  timeout_s=timeout_s,
141
208
  )
142
- tools = AbstractCoreToolExecutor()
209
+ tools = AbstractCoreToolExecutor(timeout_s=tool_timeout_s)
210
+ try:
211
+ setter = getattr(tools, "set_timeout_s", None)
212
+ if callable(setter):
213
+ setter(tool_timeout_s)
214
+ except Exception:
215
+ pass
143
216
  handlers = build_effect_handlers(llm=llm_client, tools=tools)
144
217
 
145
- return Runtime(run_store=run_store, ledger_store=ledger_store, effect_handlers=handlers, context=context)
218
+ return Runtime(
219
+ run_store=run_store,
220
+ ledger_store=ledger_store,
221
+ effect_handlers=handlers,
222
+ context=context,
223
+ artifact_store=artifact_store,
224
+ )
146
225
 
147
226
 
148
227
  def create_local_file_runtime(
@@ -153,8 +232,10 @@ def create_local_file_runtime(
153
232
  llm_kwargs: Optional[Dict[str, Any]] = None,
154
233
  context: Optional[Any] = None,
155
234
  config: Optional[RuntimeConfig] = None,
235
+ tool_timeout_s: float = DEFAULT_TOOL_TIMEOUT_S,
156
236
  ) -> Runtime:
157
237
  run_store, ledger_store = _default_file_stores(base_dir=base_dir)
238
+ artifact_store = FileArtifactStore(base_dir)
158
239
  return create_local_runtime(
159
240
  provider=provider,
160
241
  model=model,
@@ -163,6 +244,8 @@ def create_local_file_runtime(
163
244
  ledger_store=ledger_store,
164
245
  context=context,
165
246
  config=config,
247
+ artifact_store=artifact_store,
248
+ tool_timeout_s=tool_timeout_s,
166
249
  )
167
250
 
168
251
 
@@ -172,10 +255,11 @@ def create_remote_file_runtime(
172
255
  server_base_url: str,
173
256
  model: str,
174
257
  headers: Optional[Dict[str, str]] = None,
175
- timeout_s: float = 60.0,
258
+ timeout_s: float = DEFAULT_LLM_TIMEOUT_S,
176
259
  context: Optional[Any] = None,
177
260
  ) -> Runtime:
178
261
  run_store, ledger_store = _default_file_stores(base_dir=base_dir)
262
+ artifact_store = FileArtifactStore(base_dir)
179
263
  return create_remote_runtime(
180
264
  server_base_url=server_base_url,
181
265
  model=model,
@@ -184,4 +268,5 @@ def create_remote_file_runtime(
184
268
  run_store=run_store,
185
269
  ledger_store=ledger_store,
186
270
  context=context,
271
+ artifact_store=artifact_store,
187
272
  )