abstractflow 0.3.0__py3-none-any.whl → 0.3.1__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.
- abstractflow/__init__.py +2 -2
- abstractflow/adapters/agent_adapter.py +2 -121
- abstractflow/adapters/control_adapter.py +2 -612
- abstractflow/adapters/effect_adapter.py +2 -642
- abstractflow/adapters/event_adapter.py +2 -304
- abstractflow/adapters/function_adapter.py +2 -94
- abstractflow/adapters/subflow_adapter.py +2 -71
- abstractflow/adapters/variable_adapter.py +2 -314
- abstractflow/cli.py +73 -28
- abstractflow/compiler.py +18 -2022
- abstractflow/core/flow.py +4 -240
- abstractflow/runner.py +59 -5
- abstractflow/visual/agent_ids.py +2 -26
- abstractflow/visual/builtins.py +2 -786
- abstractflow/visual/code_executor.py +2 -211
- abstractflow/visual/executor.py +319 -2140
- abstractflow/visual/interfaces.py +103 -10
- abstractflow/visual/models.py +26 -1
- abstractflow/visual/session_runner.py +23 -9
- abstractflow/visual/workspace_scoped_tools.py +11 -243
- abstractflow/workflow_bundle.py +290 -0
- abstractflow-0.3.1.dist-info/METADATA +186 -0
- abstractflow-0.3.1.dist-info/RECORD +33 -0
- {abstractflow-0.3.0.dist-info → abstractflow-0.3.1.dist-info}/WHEEL +1 -1
- abstractflow-0.3.0.dist-info/METADATA +0 -413
- abstractflow-0.3.0.dist-info/RECORD +0 -32
- {abstractflow-0.3.0.dist-info → abstractflow-0.3.1.dist-info}/entry_points.txt +0 -0
- {abstractflow-0.3.0.dist-info → abstractflow-0.3.1.dist-info}/licenses/LICENSE +0 -0
- {abstractflow-0.3.0.dist-info → abstractflow-0.3.1.dist-info}/top_level.txt +0 -0
|
@@ -62,27 +62,35 @@ def get_interface_specs() -> Dict[str, VisualFlowInterfaceSpec]:
|
|
|
62
62
|
return {
|
|
63
63
|
ABSTRACTCODE_AGENT_V1: VisualFlowInterfaceSpec(
|
|
64
64
|
interface_id=ABSTRACTCODE_AGENT_V1,
|
|
65
|
-
label="
|
|
65
|
+
label="RunnableFlow (v1)",
|
|
66
66
|
description=(
|
|
67
|
-
"Host-configurable
|
|
67
|
+
"Host-configurable prompt → response contract for running a workflow in chat-like clients (AbstractCode, AbstractObserver, etc)."
|
|
68
68
|
),
|
|
69
|
-
# NOTE: We require host routing/policy pins (provider/model/tools) so workflows
|
|
70
|
-
# can be driven by AbstractCode without hardcoding node configs.
|
|
71
69
|
required_start_outputs={
|
|
72
|
-
"request": "string",
|
|
73
70
|
"provider": "provider",
|
|
74
71
|
"model": "model",
|
|
75
|
-
"
|
|
72
|
+
"prompt": "string",
|
|
73
|
+
},
|
|
74
|
+
required_end_inputs={
|
|
75
|
+
"response": "string",
|
|
76
|
+
"success": "boolean",
|
|
77
|
+
"meta": "object",
|
|
76
78
|
},
|
|
77
|
-
required_end_inputs={"response": "string"},
|
|
78
79
|
recommended_start_outputs={
|
|
80
|
+
"use_context": "boolean",
|
|
81
|
+
"memory": "memory",
|
|
79
82
|
"context": "object",
|
|
83
|
+
"system": "string",
|
|
84
|
+
"tools": "tools",
|
|
80
85
|
"max_iterations": "number",
|
|
86
|
+
"max_in_tokens": "number",
|
|
87
|
+
"temperature": "number",
|
|
88
|
+
"seed": "number",
|
|
89
|
+
"resp_schema": "object",
|
|
81
90
|
},
|
|
82
91
|
recommended_end_inputs={
|
|
83
|
-
|
|
92
|
+
# Optional but commonly wired for host UX:
|
|
84
93
|
"scratchpad": "object",
|
|
85
|
-
"raw_result": "object",
|
|
86
94
|
},
|
|
87
95
|
),
|
|
88
96
|
}
|
|
@@ -211,6 +219,39 @@ def apply_visual_flow_interface_scaffold(
|
|
|
211
219
|
pins.insert(0, {"id": pin_id, "label": "", "type": "execution"})
|
|
212
220
|
return True
|
|
213
221
|
|
|
222
|
+
def _reorder_pins(pins: list[Any], *, desired_ids: list[str]) -> bool:
|
|
223
|
+
"""Reorder pins in-place so interface pins appear in a stable, readable order."""
|
|
224
|
+
if not isinstance(pins, list) or not desired_ids:
|
|
225
|
+
return False
|
|
226
|
+
ordered: list[Any] = []
|
|
227
|
+
seen: set[str] = set()
|
|
228
|
+
|
|
229
|
+
def _first_pin(pid: str) -> Any | None:
|
|
230
|
+
for p in pins:
|
|
231
|
+
if isinstance(p, dict) and p.get("id") == pid:
|
|
232
|
+
return p
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
for pid in desired_ids:
|
|
236
|
+
if pid in seen:
|
|
237
|
+
continue
|
|
238
|
+
p = _first_pin(pid)
|
|
239
|
+
if p is None:
|
|
240
|
+
continue
|
|
241
|
+
ordered.append(p)
|
|
242
|
+
seen.add(pid)
|
|
243
|
+
|
|
244
|
+
for p in pins:
|
|
245
|
+
pid = p.get("id") if isinstance(p, dict) else None
|
|
246
|
+
if isinstance(pid, str) and pid in seen:
|
|
247
|
+
continue
|
|
248
|
+
ordered.append(p)
|
|
249
|
+
|
|
250
|
+
if ordered == pins:
|
|
251
|
+
return False
|
|
252
|
+
pins[:] = ordered
|
|
253
|
+
return True
|
|
254
|
+
|
|
214
255
|
# Desired pins (required + optional recommended).
|
|
215
256
|
start_pins = dict(spec.required_start_outputs)
|
|
216
257
|
end_pins = dict(spec.required_end_inputs)
|
|
@@ -312,6 +353,24 @@ def apply_visual_flow_interface_scaffold(
|
|
|
312
353
|
for pid, t in start_pins.items():
|
|
313
354
|
changed = _ensure_pin(outputs, pin_id=str(pid), type_str=str(t), label=str(pid)) or changed
|
|
314
355
|
|
|
356
|
+
desired_start_order = [
|
|
357
|
+
"exec-out",
|
|
358
|
+
"use_context",
|
|
359
|
+
"memory",
|
|
360
|
+
"context",
|
|
361
|
+
"provider",
|
|
362
|
+
"model",
|
|
363
|
+
"system",
|
|
364
|
+
"prompt",
|
|
365
|
+
"tools",
|
|
366
|
+
"max_iterations",
|
|
367
|
+
"max_in_tokens",
|
|
368
|
+
"temperature",
|
|
369
|
+
"seed",
|
|
370
|
+
"resp_schema",
|
|
371
|
+
]
|
|
372
|
+
changed = _reorder_pins(outputs, desired_ids=desired_start_order) or changed
|
|
373
|
+
|
|
315
374
|
# Ensure pins on all end nodes.
|
|
316
375
|
for end in end_nodes:
|
|
317
376
|
end_data = getattr(end, "data", None)
|
|
@@ -324,10 +383,45 @@ def apply_visual_flow_interface_scaffold(
|
|
|
324
383
|
inputs = []
|
|
325
384
|
end_data["inputs"] = inputs
|
|
326
385
|
changed = True
|
|
386
|
+
|
|
387
|
+
# Backward-compat cleanup: remove deprecated interface pins (`result` / `raw_result`)
|
|
388
|
+
# when they are not part of the current desired contract.
|
|
389
|
+
deprecated_end_pins = {"result", "raw_result"}
|
|
390
|
+
if not any(pid in end_pins for pid in deprecated_end_pins):
|
|
391
|
+
removed: set[str] = set()
|
|
392
|
+
kept: list[Any] = []
|
|
393
|
+
for p in inputs:
|
|
394
|
+
pid = p.get("id") if isinstance(p, dict) else None
|
|
395
|
+
if isinstance(pid, str) and pid in deprecated_end_pins:
|
|
396
|
+
removed.add(pid)
|
|
397
|
+
changed = True
|
|
398
|
+
continue
|
|
399
|
+
kept.append(p)
|
|
400
|
+
if removed:
|
|
401
|
+
inputs[:] = kept
|
|
402
|
+
# Remove edges that targeted the deprecated pins (best-effort).
|
|
403
|
+
try:
|
|
404
|
+
flow_edges = getattr(flow, "edges", None)
|
|
405
|
+
if isinstance(flow_edges, list):
|
|
406
|
+
flow.edges = [
|
|
407
|
+
e
|
|
408
|
+
for e in flow_edges
|
|
409
|
+
if not (
|
|
410
|
+
getattr(e, "target", None) == getattr(end, "id", None)
|
|
411
|
+
and getattr(e, "targetHandle", None) in removed
|
|
412
|
+
)
|
|
413
|
+
]
|
|
414
|
+
except Exception:
|
|
415
|
+
pass
|
|
416
|
+
|
|
327
417
|
changed = _ensure_exec_pin(inputs, pin_id="exec-in", direction="in") or changed
|
|
328
418
|
for pid, t in end_pins.items():
|
|
329
419
|
changed = _ensure_pin(inputs, pin_id=str(pid), type_str=str(t), label=str(pid)) or changed
|
|
330
420
|
|
|
421
|
+
# Keep interface pins in a predictable order for UX.
|
|
422
|
+
desired_end_order = ["exec-in", "response", "success", "meta", "scratchpad"]
|
|
423
|
+
changed = _reorder_pins(inputs, desired_ids=desired_end_order) or changed
|
|
424
|
+
|
|
331
425
|
# Write back nodes list if it was reconstructed.
|
|
332
426
|
try:
|
|
333
427
|
flow.nodes = nodes # type: ignore[assignment]
|
|
@@ -344,4 +438,3 @@ def apply_visual_flow_interface_scaffold(
|
|
|
344
438
|
pass
|
|
345
439
|
|
|
346
440
|
return bool(changed)
|
|
347
|
-
|
abstractflow/visual/models.py
CHANGED
|
@@ -23,6 +23,9 @@ class PinType(str, Enum):
|
|
|
23
23
|
NUMBER = "number" # Green #00FF00 - Integer/Float
|
|
24
24
|
BOOLEAN = "boolean" # Red #FF0000 - True/False
|
|
25
25
|
OBJECT = "object" # Cyan #00FFFF - JSON objects
|
|
26
|
+
MEMORY = "memory" # Mint - Memory configuration object (KG/span/session controls)
|
|
27
|
+
ASSERTION = "assertion" # Teal - KG assertion object (subject/predicate/object + metadata)
|
|
28
|
+
ASSERTIONS = "assertions" # Teal - List of KG assertion objects (assertion[])
|
|
26
29
|
ARRAY = "array" # Orange #FF8800 - Collections
|
|
27
30
|
TOOLS = "tools" # Orange - Tool allowlist (string[])
|
|
28
31
|
PROVIDER = "provider" # Cyan-blue - LLM provider id/name (string-like)
|
|
@@ -56,6 +59,8 @@ class NodeType(str, Enum):
|
|
|
56
59
|
POWER = "power"
|
|
57
60
|
ABS = "abs"
|
|
58
61
|
ROUND = "round"
|
|
62
|
+
RANDOM_INT = "random_int"
|
|
63
|
+
RANDOM_FLOAT = "random_float"
|
|
59
64
|
# String
|
|
60
65
|
CONCAT = "concat"
|
|
61
66
|
SPLIT = "split"
|
|
@@ -67,6 +72,8 @@ class NodeType(str, Enum):
|
|
|
67
72
|
TRIM = "trim"
|
|
68
73
|
SUBSTRING = "substring"
|
|
69
74
|
LENGTH = "length"
|
|
75
|
+
CONTAINS = "contains"
|
|
76
|
+
REPLACE = "replace"
|
|
70
77
|
# Control
|
|
71
78
|
IF = "if"
|
|
72
79
|
SWITCH = "switch"
|
|
@@ -85,18 +92,28 @@ class NodeType(str, Enum):
|
|
|
85
92
|
SET = "set"
|
|
86
93
|
MERGE = "merge"
|
|
87
94
|
MAKE_ARRAY = "make_array"
|
|
95
|
+
MAKE_OBJECT = "make_object"
|
|
96
|
+
MAKE_CONTEXT = "make_context"
|
|
97
|
+
MAKE_META = "make_meta"
|
|
98
|
+
MAKE_SCRATCHPAD = "make_scratchpad"
|
|
99
|
+
GET_ELEMENT = "get_element"
|
|
100
|
+
GET_RANDOM_ELEMENT = "get_random_element"
|
|
88
101
|
ARRAY_MAP = "array_map"
|
|
89
102
|
ARRAY_FILTER = "array_filter"
|
|
90
103
|
ARRAY_CONCAT = "array_concat"
|
|
91
104
|
ARRAY_LENGTH = "array_length"
|
|
92
105
|
ARRAY_APPEND = "array_append"
|
|
93
106
|
ARRAY_DEDUP = "array_dedup"
|
|
107
|
+
HAS_TOOLS = "has_tools"
|
|
108
|
+
ADD_MESSAGE = "add_message"
|
|
109
|
+
GET_CONTEXT = "get_context"
|
|
94
110
|
GET_VAR = "get_var"
|
|
95
111
|
SET_VAR = "set_var"
|
|
96
112
|
SET_VARS = "set_vars"
|
|
97
113
|
SET_VAR_PROPERTY = "set_var_property"
|
|
98
114
|
PARSE_JSON = "parse_json"
|
|
99
115
|
STRINGIFY_JSON = "stringify_json"
|
|
116
|
+
FORMAT_TOOL_RESULTS = "format_tool_results"
|
|
100
117
|
AGENT_TRACE_REPORT = "agent_trace_report"
|
|
101
118
|
BREAK_OBJECT = "break_object"
|
|
102
119
|
SYSTEM_DATETIME = "system_datetime"
|
|
@@ -110,6 +127,7 @@ class NodeType(str, Enum):
|
|
|
110
127
|
LITERAL_JSON = "literal_json"
|
|
111
128
|
JSON_SCHEMA = "json_schema"
|
|
112
129
|
LITERAL_ARRAY = "literal_array"
|
|
130
|
+
TOOL_PARAMETERS = "tool_parameters"
|
|
113
131
|
# Effects
|
|
114
132
|
ASK_USER = "ask_user"
|
|
115
133
|
ANSWER_USER = "answer_user"
|
|
@@ -121,8 +139,15 @@ class NodeType(str, Enum):
|
|
|
121
139
|
WRITE_FILE = "write_file"
|
|
122
140
|
MEMORY_NOTE = "memory_note"
|
|
123
141
|
MEMORY_QUERY = "memory_query"
|
|
142
|
+
MEMORY_TAG = "memory_tag"
|
|
143
|
+
MEMORY_COMPACT = "memory_compact"
|
|
124
144
|
MEMORY_REHYDRATE = "memory_rehydrate"
|
|
145
|
+
MEMORY_KG_ASSERT = "memory_kg_assert"
|
|
146
|
+
MEMORY_KG_QUERY = "memory_kg_query"
|
|
147
|
+
MEMORY_KG_RESOLVE = "memory_kg_resolve"
|
|
148
|
+
MEMACT_COMPOSE = "memact_compose"
|
|
125
149
|
TOOL_CALLS = "tool_calls"
|
|
150
|
+
CALL_TOOL = "call_tool"
|
|
126
151
|
TOOLS_ALLOWLIST = "tools_allowlist"
|
|
127
152
|
BOOL_VAR = "bool_var"
|
|
128
153
|
VAR_DECL = "var_decl"
|
|
@@ -176,7 +201,7 @@ class VisualFlow(BaseModel):
|
|
|
176
201
|
name: str
|
|
177
202
|
description: str = ""
|
|
178
203
|
# Optional interface markers (host contracts).
|
|
179
|
-
# Example: ["abstractcode.agent.v1"] to indicate this workflow can be run as
|
|
204
|
+
# Example: ["abstractcode.agent.v1"] to indicate this workflow can be run as a RunnableFlow in chat-like clients.
|
|
180
205
|
interfaces: List[str] = Field(default_factory=list)
|
|
181
206
|
nodes: List[VisualNode] = Field(default_factory=list)
|
|
182
207
|
edges: List[VisualEdge] = Field(default_factory=list)
|
|
@@ -37,18 +37,27 @@ class VisualSessionRunner(FlowRunner):
|
|
|
37
37
|
def event_listener_run_ids(self) -> List[str]:
|
|
38
38
|
return list(self._event_listener_run_ids)
|
|
39
39
|
|
|
40
|
-
def start(
|
|
41
|
-
|
|
40
|
+
def start(
|
|
41
|
+
self,
|
|
42
|
+
input_data: Optional[Dict[str, Any]] = None,
|
|
43
|
+
*,
|
|
44
|
+
actor_id: Optional[str] = None,
|
|
45
|
+
session_id: Optional[str] = None,
|
|
46
|
+
) -> str:
|
|
47
|
+
run_id = super().start(input_data, actor_id=actor_id, session_id=session_id)
|
|
42
48
|
|
|
43
49
|
# Default session_id to the root run_id for session-scoped events.
|
|
50
|
+
# If a session_id is explicitly provided, preserve it.
|
|
51
|
+
effective_session_id = session_id
|
|
44
52
|
try:
|
|
45
53
|
state = self.runtime.get_state(run_id)
|
|
46
54
|
if not getattr(state, "session_id", None):
|
|
47
55
|
state.session_id = run_id # type: ignore[attr-defined]
|
|
48
56
|
self.runtime.run_store.save(state)
|
|
57
|
+
effective_session_id = str(getattr(state, "session_id", None) or run_id).strip() or run_id
|
|
49
58
|
except Exception:
|
|
50
59
|
# Best-effort; session-scoped keys will fall back to run_id if missing.
|
|
51
|
-
|
|
60
|
+
effective_session_id = str(session_id).strip() if isinstance(session_id, str) and session_id.strip() else run_id
|
|
52
61
|
|
|
53
62
|
if not self._event_listener_specs:
|
|
54
63
|
return run_id
|
|
@@ -59,7 +68,8 @@ class VisualSessionRunner(FlowRunner):
|
|
|
59
68
|
child_run_id = self.runtime.start(
|
|
60
69
|
workflow=spec,
|
|
61
70
|
vars={},
|
|
62
|
-
session_id=
|
|
71
|
+
session_id=effective_session_id,
|
|
72
|
+
actor_id=actor_id,
|
|
63
73
|
parent_run_id=run_id,
|
|
64
74
|
)
|
|
65
75
|
# Advance the listener to its first WAIT_EVENT (On Event node).
|
|
@@ -72,7 +82,13 @@ class VisualSessionRunner(FlowRunner):
|
|
|
72
82
|
|
|
73
83
|
return run_id
|
|
74
84
|
|
|
75
|
-
def run(
|
|
85
|
+
def run(
|
|
86
|
+
self,
|
|
87
|
+
input_data: Optional[Dict[str, Any]] = None,
|
|
88
|
+
*,
|
|
89
|
+
actor_id: Optional[str] = None,
|
|
90
|
+
session_id: Optional[str] = None,
|
|
91
|
+
) -> Dict[str, Any]:
|
|
76
92
|
"""Execute the root run and drive session-level listener runs.
|
|
77
93
|
|
|
78
94
|
Rationale:
|
|
@@ -81,7 +97,7 @@ class VisualSessionRunner(FlowRunner):
|
|
|
81
97
|
"""
|
|
82
98
|
from abstractruntime.core.models import RunStatus, WaitReason
|
|
83
99
|
|
|
84
|
-
run_id = self.start(input_data)
|
|
100
|
+
run_id = self.start(input_data, actor_id=actor_id, session_id=session_id)
|
|
85
101
|
runtime = self.runtime
|
|
86
102
|
|
|
87
103
|
def _list_session_runs() -> List[str]:
|
|
@@ -150,7 +166,7 @@ class VisualSessionRunner(FlowRunner):
|
|
|
150
166
|
runtime.cancel_run(cid, reason="Session completed")
|
|
151
167
|
except Exception:
|
|
152
168
|
pass
|
|
153
|
-
return state.output
|
|
169
|
+
return self._normalize_completed_output(state.output)
|
|
154
170
|
# Otherwise, keep driving until children settle into waits/terminal.
|
|
155
171
|
continue
|
|
156
172
|
|
|
@@ -164,5 +180,3 @@ class VisualSessionRunner(FlowRunner):
|
|
|
164
180
|
"state": state,
|
|
165
181
|
"wait_key": state.waiting.wait_key if state.waiting else None,
|
|
166
182
|
}
|
|
167
|
-
|
|
168
|
-
|
|
@@ -1,251 +1,20 @@
|
|
|
1
|
-
"""Workspace-scoped tool execution helpers.
|
|
1
|
+
"""Workspace-scoped tool execution helpers (AbstractFlow re-export).
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
- Relative paths resolve under `workspace_root`.
|
|
7
|
-
- Absolute paths are only allowed if they remain under `workspace_root`.
|
|
8
|
-
- `execute_command` defaults to `working_directory=workspace_root` when not specified.
|
|
9
|
-
|
|
10
|
-
This is implemented as a thin wrapper around an AbstractRuntime ToolExecutor that
|
|
11
|
-
rewrites/validates tool call arguments before delegating.
|
|
3
|
+
The core implementation lives in AbstractRuntime's AbstractCore integration so it can
|
|
4
|
+
be shared across hosts/clients. AbstractFlow re-exports the types to preserve existing
|
|
5
|
+
imports (`abstractflow.visual.workspace_scoped_tools`).
|
|
12
6
|
"""
|
|
13
7
|
|
|
14
8
|
from __future__ import annotations
|
|
15
9
|
|
|
16
|
-
from
|
|
17
|
-
import os
|
|
18
|
-
from pathlib import Path
|
|
19
|
-
from typing import Any, Dict, List, Optional, Tuple
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def _resolve_no_strict(path: Path) -> Path:
|
|
23
|
-
"""Resolve without requiring the path to exist (best-effort across py versions)."""
|
|
24
|
-
try:
|
|
25
|
-
return path.resolve(strict=False)
|
|
26
|
-
except TypeError: # pragma: no cover (older python)
|
|
27
|
-
return path.resolve()
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def _find_repo_root_from_here(*, start: Path, max_hops: int = 10) -> Optional[Path]:
|
|
31
|
-
"""Best-effort monorepo root detection for local/dev runs."""
|
|
32
|
-
cur = _resolve_no_strict(start)
|
|
33
|
-
for _ in range(max_hops):
|
|
34
|
-
docs = cur / "docs" / "KnowledgeBase.md"
|
|
35
|
-
if docs.exists():
|
|
36
|
-
return cur
|
|
37
|
-
if (cur / "abstractflow").exists() and (cur / "abstractcore").exists() and (cur / "abstractruntime").exists():
|
|
38
|
-
return cur
|
|
39
|
-
nxt = cur.parent
|
|
40
|
-
if nxt == cur:
|
|
41
|
-
break
|
|
42
|
-
cur = nxt
|
|
43
|
-
return None
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def resolve_workspace_base_dir() -> Path:
|
|
47
|
-
"""Base directory against which relative workspace roots are resolved.
|
|
48
|
-
|
|
49
|
-
Priority:
|
|
50
|
-
- `ABSTRACTFLOW_WORKSPACE_BASE_DIR` env var, if set.
|
|
51
|
-
- Best-effort monorepo root detection from this file location.
|
|
52
|
-
- Current working directory.
|
|
53
|
-
"""
|
|
54
|
-
env = os.getenv("ABSTRACTFLOW_WORKSPACE_BASE_DIR")
|
|
55
|
-
if isinstance(env, str) and env.strip():
|
|
56
|
-
return _resolve_no_strict(Path(env.strip()).expanduser())
|
|
57
|
-
|
|
58
|
-
here_dir = Path(__file__).resolve().parent
|
|
59
|
-
guessed = _find_repo_root_from_here(start=here_dir)
|
|
60
|
-
if guessed is not None:
|
|
61
|
-
return guessed
|
|
62
|
-
|
|
63
|
-
return _resolve_no_strict(Path.cwd())
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def _resolve_under_root(*, root: Path, user_path: str) -> Path:
|
|
67
|
-
"""Resolve a user-provided path under a workspace root and ensure it doesn't escape."""
|
|
68
|
-
p = Path(str(user_path or "").strip()).expanduser()
|
|
69
|
-
if not p.is_absolute():
|
|
70
|
-
p = root / p
|
|
71
|
-
resolved = _resolve_no_strict(p)
|
|
72
|
-
root_resolved = _resolve_no_strict(root)
|
|
73
|
-
try:
|
|
74
|
-
resolved.relative_to(root_resolved)
|
|
75
|
-
except Exception as e:
|
|
76
|
-
raise ValueError(f"Path escapes workspace_root: '{user_path}'") from e
|
|
77
|
-
return resolved
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def _normalize_arguments(raw: Any) -> Dict[str, Any]:
|
|
81
|
-
if raw is None:
|
|
82
|
-
return {}
|
|
83
|
-
if isinstance(raw, dict):
|
|
84
|
-
return dict(raw)
|
|
85
|
-
# Some models emit JSON strings for args.
|
|
86
|
-
if isinstance(raw, str) and raw.strip():
|
|
87
|
-
import json
|
|
88
|
-
|
|
89
|
-
try:
|
|
90
|
-
parsed = json.loads(raw)
|
|
91
|
-
except Exception:
|
|
92
|
-
return {}
|
|
93
|
-
return dict(parsed) if isinstance(parsed, dict) else {}
|
|
94
|
-
return {}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
@dataclass(frozen=True)
|
|
98
|
-
class WorkspaceScope:
|
|
99
|
-
root: Path
|
|
100
|
-
|
|
101
|
-
@classmethod
|
|
102
|
-
def from_input_data(
|
|
103
|
-
cls, input_data: Dict[str, Any], *, key: str = "workspace_root", base_dir: Optional[Path] = None
|
|
104
|
-
) -> Optional["WorkspaceScope"]:
|
|
105
|
-
raw = input_data.get(key)
|
|
106
|
-
if not isinstance(raw, str) or not raw.strip():
|
|
107
|
-
return None
|
|
108
|
-
base = base_dir or resolve_workspace_base_dir()
|
|
109
|
-
root = Path(raw.strip()).expanduser()
|
|
110
|
-
if not root.is_absolute():
|
|
111
|
-
root = base / root
|
|
112
|
-
root = _resolve_no_strict(root)
|
|
113
|
-
if root.exists() and not root.is_dir():
|
|
114
|
-
raise ValueError(f"workspace_root must be a directory (got file): {raw}")
|
|
115
|
-
root.mkdir(parents=True, exist_ok=True)
|
|
116
|
-
return cls(root=root)
|
|
10
|
+
from typing import Any
|
|
117
11
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
self._delegate = delegate
|
|
125
|
-
|
|
126
|
-
def set_timeout_s(self, timeout_s: Optional[float]) -> None: # pragma: no cover (depends on delegate)
|
|
127
|
-
setter = getattr(self._delegate, "set_timeout_s", None)
|
|
128
|
-
if callable(setter):
|
|
129
|
-
setter(timeout_s)
|
|
130
|
-
|
|
131
|
-
def execute(self, *, tool_calls: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
132
|
-
# Preprocess: rewrite and pre-block invalid calls so we don't crash the whole run.
|
|
133
|
-
blocked: Dict[Tuple[int, str], Dict[str, Any]] = {}
|
|
134
|
-
to_execute: List[Dict[str, Any]] = []
|
|
135
|
-
|
|
136
|
-
for i, tc in enumerate(tool_calls or []):
|
|
137
|
-
name = str(tc.get("name", "") or "")
|
|
138
|
-
call_id = str(tc.get("call_id") or tc.get("id") or f"call_{i}")
|
|
139
|
-
args = _normalize_arguments(tc.get("arguments"))
|
|
140
|
-
|
|
141
|
-
try:
|
|
142
|
-
rewritten_args = self._rewrite_args(tool_name=name, args=args)
|
|
143
|
-
except Exception as e:
|
|
144
|
-
blocked[(i, call_id)] = {
|
|
145
|
-
"call_id": call_id,
|
|
146
|
-
"name": name,
|
|
147
|
-
"success": False,
|
|
148
|
-
"output": None,
|
|
149
|
-
"error": str(e),
|
|
150
|
-
}
|
|
151
|
-
continue
|
|
152
|
-
|
|
153
|
-
rewritten = dict(tc)
|
|
154
|
-
rewritten["name"] = name
|
|
155
|
-
rewritten["call_id"] = call_id
|
|
156
|
-
rewritten["arguments"] = rewritten_args
|
|
157
|
-
to_execute.append(rewritten)
|
|
158
|
-
|
|
159
|
-
delegate_result = self._delegate.execute(tool_calls=to_execute)
|
|
160
|
-
|
|
161
|
-
# If the delegate didn't execute tools, we can't merge blocked results meaningfully.
|
|
162
|
-
if not isinstance(delegate_result, dict) or delegate_result.get("mode") != "executed":
|
|
163
|
-
return delegate_result
|
|
164
|
-
|
|
165
|
-
results = delegate_result.get("results")
|
|
166
|
-
if not isinstance(results, list):
|
|
167
|
-
results = []
|
|
168
|
-
|
|
169
|
-
by_id: Dict[str, Dict[str, Any]] = {}
|
|
170
|
-
for r in results:
|
|
171
|
-
if not isinstance(r, dict):
|
|
172
|
-
continue
|
|
173
|
-
rid = str(r.get("call_id") or "")
|
|
174
|
-
if rid:
|
|
175
|
-
by_id[rid] = r
|
|
176
|
-
|
|
177
|
-
merged: List[Dict[str, Any]] = []
|
|
178
|
-
for i, tc in enumerate(tool_calls or []):
|
|
179
|
-
call_id = str(tc.get("call_id") or tc.get("id") or f"call_{i}")
|
|
180
|
-
key = (i, call_id)
|
|
181
|
-
if key in blocked:
|
|
182
|
-
merged.append(blocked[key])
|
|
183
|
-
continue
|
|
184
|
-
r = by_id.get(call_id)
|
|
185
|
-
if r is None:
|
|
186
|
-
merged.append(
|
|
187
|
-
{
|
|
188
|
-
"call_id": call_id,
|
|
189
|
-
"name": str(tc.get("name", "") or ""),
|
|
190
|
-
"success": False,
|
|
191
|
-
"output": None,
|
|
192
|
-
"error": "Tool result missing (internal error)",
|
|
193
|
-
}
|
|
194
|
-
)
|
|
195
|
-
continue
|
|
196
|
-
merged.append(r)
|
|
197
|
-
|
|
198
|
-
return {"mode": "executed", "results": merged}
|
|
199
|
-
|
|
200
|
-
def _rewrite_args(self, *, tool_name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
201
|
-
"""Rewrite tool args so file operations are scoped under workspace_root."""
|
|
202
|
-
root = self._scope.root
|
|
203
|
-
out = dict(args or {})
|
|
204
|
-
|
|
205
|
-
def _rewrite_path_field(field: str, *, default_to_root: bool = False) -> None:
|
|
206
|
-
raw = out.get(field)
|
|
207
|
-
if (raw is None or (isinstance(raw, str) and not raw.strip())) and default_to_root:
|
|
208
|
-
out[field] = str(_resolve_no_strict(root))
|
|
209
|
-
return
|
|
210
|
-
if raw is None:
|
|
211
|
-
return
|
|
212
|
-
if not isinstance(raw, str):
|
|
213
|
-
raw = str(raw)
|
|
214
|
-
resolved = _resolve_under_root(root=root, user_path=raw)
|
|
215
|
-
out[field] = str(resolved)
|
|
216
|
-
|
|
217
|
-
# Filesystem-ish tools (AbstractCore common tools)
|
|
218
|
-
if tool_name == "list_files":
|
|
219
|
-
_rewrite_path_field("directory_path", default_to_root=True)
|
|
220
|
-
return out
|
|
221
|
-
if tool_name == "search_files":
|
|
222
|
-
_rewrite_path_field("path", default_to_root=True)
|
|
223
|
-
return out
|
|
224
|
-
if tool_name == "analyze_code":
|
|
225
|
-
_rewrite_path_field("file_path")
|
|
226
|
-
if "file_path" not in out:
|
|
227
|
-
raise ValueError("analyze_code requires file_path")
|
|
228
|
-
return out
|
|
229
|
-
if tool_name == "read_file":
|
|
230
|
-
_rewrite_path_field("file_path")
|
|
231
|
-
if "file_path" not in out:
|
|
232
|
-
raise ValueError("read_file requires file_path")
|
|
233
|
-
return out
|
|
234
|
-
if tool_name == "write_file":
|
|
235
|
-
_rewrite_path_field("file_path")
|
|
236
|
-
if "file_path" not in out:
|
|
237
|
-
raise ValueError("write_file requires file_path")
|
|
238
|
-
return out
|
|
239
|
-
if tool_name == "edit_file":
|
|
240
|
-
_rewrite_path_field("file_path")
|
|
241
|
-
if "file_path" not in out:
|
|
242
|
-
raise ValueError("edit_file requires file_path")
|
|
243
|
-
return out
|
|
244
|
-
if tool_name == "execute_command":
|
|
245
|
-
_rewrite_path_field("working_directory", default_to_root=True)
|
|
246
|
-
return out
|
|
247
|
-
|
|
248
|
-
return out
|
|
12
|
+
from abstractruntime.integrations.abstractcore.workspace_scoped_tools import ( # noqa: F401
|
|
13
|
+
WorkspaceScope,
|
|
14
|
+
WorkspaceScopedToolExecutor,
|
|
15
|
+
resolve_workspace_base_dir,
|
|
16
|
+
resolve_user_path,
|
|
17
|
+
)
|
|
249
18
|
|
|
250
19
|
|
|
251
20
|
def build_scoped_tool_executor(*, scope: WorkspaceScope) -> Any:
|
|
@@ -258,4 +27,3 @@ def build_scoped_tool_executor(*, scope: WorkspaceScope) -> Any:
|
|
|
258
27
|
|
|
259
28
|
|
|
260
29
|
|
|
261
|
-
|