run-dbt 0.1.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.
pdt_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """PDT CLI package."""
pdt_cli/adapter.py ADDED
@@ -0,0 +1,206 @@
1
+ import os
2
+ import json
3
+ import re
4
+ import tempfile
5
+ import subprocess
6
+ import uuid
7
+ from abc import ABC, abstractmethod
8
+ from typing import List, Dict, Any, Optional
9
+ from pydantic import BaseModel, Field
10
+
11
+ class ToolCall(BaseModel):
12
+ call_id: str
13
+ name: str
14
+ arguments: Dict[str, Any]
15
+
16
+ class CompletionResponse(BaseModel):
17
+ text: Optional[str] = None
18
+ tool_calls: List[ToolCall] = Field(default_factory=list)
19
+
20
+ class LLMAdapter(ABC):
21
+ @abstractmethod
22
+ def complete(self, prompt: str, system_instruction: str = None, tools: Optional[List[Dict[str, Any]]] = None) -> CompletionResponse:
23
+ """Sends a prompt to the model and returns a structured response."""
24
+ pass
25
+
26
+ class CLIDelegatorAdapter(LLMAdapter):
27
+ def __init__(self, command: str, args: List[str]):
28
+ self.command = command
29
+ self.args = args
30
+
31
+ def complete(self, prompt: str, system_instruction: str = None, tools: Optional[List[Dict[str, Any]]] = None) -> CompletionResponse:
32
+ full_system = system_instruction or ""
33
+ if tools:
34
+ tools_instruction = (
35
+ "\n\nYou have access to the following tools:\n"
36
+ + json.dumps(tools, indent=2)
37
+ + "\nTo call a tool, you MUST output a JSON object with a single top-level key 'tool_call', like this:\n"
38
+ + '{"tool_call": {"name": "tool_name", "arguments": {"arg1": "val1"}}}'
39
+ + "\nDo not output any other text when calling a tool."
40
+ )
41
+ full_system += tools_instruction
42
+
43
+ full_prompt = f"{full_system}\n\n{prompt}" if full_system else prompt
44
+
45
+ with tempfile.NamedTemporaryFile(mode="w+", delete=False, suffix=".txt") as temp:
46
+ temp.write(full_prompt)
47
+ temp_path = temp.name
48
+
49
+ resolved_args = [arg.replace("{prompt_file}", temp_path) for arg in self.args]
50
+
51
+ try:
52
+ result = subprocess.run(
53
+ [self.command] + resolved_args,
54
+ capture_output=True,
55
+ text=True,
56
+ check=True
57
+ )
58
+ output = result.stdout.strip()
59
+
60
+ tool_calls = []
61
+ # Try to parse entire output as JSON
62
+ try:
63
+ data = json.loads(output)
64
+ if "tool_call" in data:
65
+ tc = data["tool_call"]
66
+ tool_calls.append(ToolCall(
67
+ call_id=uuid.uuid4().hex[:8],
68
+ name=tc["name"],
69
+ arguments=tc.get("arguments") or {}
70
+ ))
71
+ output = None
72
+ except Exception:
73
+ # Try finding JSON block in the output
74
+ json_match = re.search(r'\{\s*"tool_call"\s*:\s*\{.*?\}\s*\}', output, re.DOTALL)
75
+ if json_match:
76
+ try:
77
+ data = json.loads(json_match.group(0))
78
+ tc = data["tool_call"]
79
+ tool_calls.append(ToolCall(
80
+ call_id=uuid.uuid4().hex[:8],
81
+ name=tc["name"],
82
+ arguments=tc.get("arguments") or {}
83
+ ))
84
+ output = output.replace(json_match.group(0), "").strip()
85
+ except Exception:
86
+ pass
87
+
88
+ return CompletionResponse(
89
+ text=output if (output and output.strip()) else None,
90
+ tool_calls=tool_calls
91
+ )
92
+ finally:
93
+ os.unlink(temp_path)
94
+
95
+ class GeminiAdapter(LLMAdapter):
96
+ def __init__(self, model: str = "gemini-2.5-flash"):
97
+ self.model = model
98
+ # Import dynamically to avoid strict dependency loading issues
99
+ from google import genai
100
+ self.client = genai.Client()
101
+
102
+ def complete(self, prompt: str, system_instruction: str = None, tools: Optional[List[Dict[str, Any]]] = None) -> CompletionResponse:
103
+ from google.genai import types
104
+
105
+ config_args = {}
106
+ if system_instruction:
107
+ config_args["system_instruction"] = system_instruction
108
+
109
+ if tools:
110
+ gemini_tools = []
111
+ for t in tools:
112
+ fd = types.FunctionDeclaration(
113
+ name=t["name"],
114
+ description=t["description"],
115
+ parameters=t.get("parameters")
116
+ )
117
+ gemini_tools.append(types.Tool(function_declarations=[fd]))
118
+ config_args["tools"] = gemini_tools
119
+
120
+ config = types.GenerateContentConfig(**config_args)
121
+
122
+ response = self.client.models.generate_content(
123
+ model=self.model,
124
+ contents=prompt,
125
+ config=config
126
+ )
127
+
128
+ tool_calls = []
129
+ if response.function_calls:
130
+ for fc in response.function_calls:
131
+ tool_calls.append(ToolCall(
132
+ call_id=uuid.uuid4().hex[:8],
133
+ name=fc.name,
134
+ arguments=fc.args
135
+ ))
136
+
137
+ return CompletionResponse(
138
+ text=response.text,
139
+ tool_calls=tool_calls
140
+ )
141
+
142
+ class OpenAIAdapter(LLMAdapter):
143
+ def __init__(self, model: str = "gpt-4o"):
144
+ self.model = model
145
+ self.api_key = os.getenv("OPENAI_API_KEY")
146
+
147
+ def complete(self, prompt: str, system_instruction: str = None, tools: Optional[List[Dict[str, Any]]] = None) -> CompletionResponse:
148
+ import requests
149
+
150
+ if not self.api_key:
151
+ raise ValueError("OPENAI_API_KEY environment variable is not set")
152
+
153
+ headers = {
154
+ "Authorization": f"Bearer {self.api_key}",
155
+ "Content-Type": "application/json"
156
+ }
157
+
158
+ messages = []
159
+ if system_instruction:
160
+ messages.append({"role": "system", "content": system_instruction})
161
+ messages.append({"role": "user", "content": prompt})
162
+
163
+ payload = {
164
+ "model": self.model,
165
+ "messages": messages
166
+ }
167
+
168
+ if tools:
169
+ openai_tools = []
170
+ for t in tools:
171
+ openai_tools.append({
172
+ "type": "function",
173
+ "function": {
174
+ "name": t["name"],
175
+ "description": t["description"],
176
+ "parameters": t.get("parameters") or {"type": "object", "properties": {}}
177
+ }
178
+ })
179
+ payload["tools"] = openai_tools
180
+
181
+ response = requests.post("https://api.openai.com/v1/chat/completions", json=payload, headers=headers)
182
+ response.raise_for_status()
183
+ resp_json = response.json()
184
+
185
+ message = resp_json["choices"][0]["message"]
186
+ text = message.get("content")
187
+
188
+ tool_calls = []
189
+ if "tool_calls" in message:
190
+ for tc in message["tool_calls"]:
191
+ if tc["type"] == "function":
192
+ func = tc["function"]
193
+ try:
194
+ args = json.loads(func["arguments"])
195
+ except Exception:
196
+ args = {}
197
+ tool_calls.append(ToolCall(
198
+ call_id=tc["id"],
199
+ name=func["name"],
200
+ arguments=args
201
+ ))
202
+
203
+ return CompletionResponse(
204
+ text=text,
205
+ tool_calls=tool_calls
206
+ )
pdt_cli/engine.py ADDED
@@ -0,0 +1,312 @@
1
+ import os
2
+ import json
3
+ import subprocess
4
+ import uuid
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Dict, Any, List, Optional
8
+ from ruamel.yaml import YAML
9
+
10
+ from pdt_cli.workspace import WorkspaceConfig
11
+ from pdt_cli.parser import ProcessDocument, WorkflowStep
12
+ from pdt_cli.resolver import resolve_reference
13
+ from pdt_cli.state import StateManager, RunState, StepState, ToolCallRecord
14
+ from pdt_cli.adapter import LLMAdapter, CLIDelegatorAdapter, GeminiAdapter, OpenAIAdapter, CompletionResponse
15
+
16
+ def step_requires_approval(step: WorkflowStep) -> bool:
17
+ content = (step.title + " " + step.instructions).lower()
18
+ return "approval" in content or "approve" in content or "gate" in content or "confirm" in content or "review" in content
19
+
20
+ class ExecutionEngine:
21
+ def __init__(self, workspace_root: Path, config: WorkspaceConfig, process_doc: ProcessDocument, run_id: Optional[str] = None):
22
+ self.workspace_root = workspace_root
23
+ self.config = config
24
+ self.process_doc = process_doc
25
+ self.state_manager = StateManager(workspace_root, run_id=run_id)
26
+
27
+ def execute(self, inputs: Dict[str, str], target_step: Optional[int] = None, resume: bool = False, approval_input: Optional[str] = None) -> RunState:
28
+ # Load or initialize state
29
+ if resume:
30
+ state = self.state_manager.load_state()
31
+ state.status = "running"
32
+ self.state_manager.save_state(state)
33
+
34
+ # Register approval output if we were waiting for approval
35
+ if state.steps:
36
+ current_step_idx = state.current_step_index
37
+ for s in state.steps:
38
+ if s.index == current_step_idx and s.status == "waiting_for_approval":
39
+ s.status = "completed"
40
+ s.end_time = datetime.now(timezone.utc)
41
+ s.output = approval_input or "Approved by user."
42
+ state.current_step_index += 1
43
+ self.state_manager.save_state(state)
44
+ self.state_manager.log_system(f"Step {current_step_idx} approved and marked completed.")
45
+ break
46
+ else:
47
+ # Check if state already exists for this run_id (if explicit run_id was provided)
48
+ try:
49
+ state = self.state_manager.load_state()
50
+ except FileNotFoundError:
51
+ # Initialize new run
52
+ step_states = []
53
+ for step in self.process_doc.steps:
54
+ step_states.append(StepState(
55
+ index=step.index,
56
+ title=step.title,
57
+ status="pending"
58
+ ))
59
+ state = self.state_manager.initialize_run(
60
+ process_id=self.process_doc.frontmatter.id,
61
+ version=self.process_doc.frontmatter.version,
62
+ inputs=inputs,
63
+ steps=step_states
64
+ )
65
+
66
+ # Main step execution loop
67
+ while state.current_step_index <= len(state.steps):
68
+ step_idx = state.current_step_index
69
+
70
+ # If target_step is specified and we are targeting a single step, check it
71
+ if target_step is not None and step_idx != target_step:
72
+ # If we've already done the target step or are not there yet, jump or break
73
+ # Let's say if we target a single step, we only run that step and then break.
74
+ if step_idx < target_step:
75
+ # Skip to the target step
76
+ state.current_step_index = target_step
77
+ self.state_manager.save_state(state)
78
+ continue
79
+ else:
80
+ # We are past the target step
81
+ break
82
+
83
+ step_state = state.steps[step_idx - 1]
84
+ step_doc = self.process_doc.steps[step_idx - 1]
85
+
86
+ self.state_manager.log_system(f"Starting execution of Step {step_idx}: {step_state.title}")
87
+ step_state.status = "running"
88
+ step_state.start_time = datetime.now(timezone.utc)
89
+ self.state_manager.save_state(state)
90
+
91
+ # Resolve references for the active step
92
+ resolved_skills = []
93
+ resolved_tools = []
94
+ resolved_schemas = []
95
+
96
+ for ref in step_doc.references:
97
+ try:
98
+ abs_path = resolve_reference(ref, self.workspace_root, self.config)
99
+ if ref.startswith("skill/"):
100
+ with open(abs_path, 'r') as f:
101
+ resolved_skills.append((ref, f.read()))
102
+ elif ref.startswith("tool/"):
103
+ yaml = YAML(typ='safe')
104
+ with open(abs_path, 'r') as f:
105
+ tool_data = yaml.load(f) or {}
106
+ resolved_tools.append((ref, tool_data, abs_path.parent))
107
+ elif ref.startswith("schema/"):
108
+ with open(abs_path, 'r') as f:
109
+ resolved_schemas.append((ref, f.read()))
110
+ except Exception as e:
111
+ self.state_manager.log_system(f"Error resolving reference {ref}: {e}")
112
+ step_state.status = "failed"
113
+ step_state.end_time = datetime.now(timezone.utc)
114
+ state.status = "failed"
115
+ self.state_manager.save_state(state)
116
+ raise
117
+
118
+ # Synthesize system prompt and bounded prompt
119
+ system_prompt = (
120
+ "You are an operational assistant executing a step-by-step Standard Operating Procedure (SOP).\n"
121
+ "Your actions must strictly adhere to the global boundaries and rules specified in the Process Description."
122
+ )
123
+
124
+ # Completed steps info
125
+ completed_info = ""
126
+ for s in state.steps[:step_idx - 1]:
127
+ completed_info += f"Step {s.index}: {s.title} ({s.status})\nOutput: {s.output}\n\n"
128
+ if not completed_info:
129
+ completed_info = "None (this is the first step).\n"
130
+
131
+ # Referenced skills text
132
+ skills_text = ""
133
+ for ref, content in resolved_skills:
134
+ skills_text += f"Skill: {ref}\n---\n{content}\n\n"
135
+ if not skills_text:
136
+ skills_text = "None\n"
137
+
138
+ # Referenced tools text
139
+ tools_text = ""
140
+ tools_list_for_adapter = []
141
+ for ref, tool_data, tool_dir in resolved_tools:
142
+ tools_text += f"Tool: {ref}\nDescription: {tool_data.get('description', '')}\nParameters Schema: {json.dumps(tool_data.get('parameters', {}))}\n\n"
143
+ tools_list_for_adapter.append({
144
+ "name": tool_data.get("name") or ref.split('/')[-1],
145
+ "description": tool_data.get("description", ""),
146
+ "parameters": tool_data.get("parameters")
147
+ })
148
+ if not tools_text:
149
+ tools_text = "None\n"
150
+
151
+ # Referenced schemas text
152
+ schemas_text = ""
153
+ for ref, content in resolved_schemas:
154
+ schemas_text += f"Schema: {ref}\n---\n{content}\n\n"
155
+ if not schemas_text:
156
+ schemas_text = "None\n"
157
+
158
+ prompt = (
159
+ f"[PROCESS DESCRIPTION CONTEXT]\n"
160
+ f"Id: {self.process_doc.frontmatter.id}\n"
161
+ f"Owner: {self.process_doc.frontmatter.owner}\n"
162
+ f"Description:\n{self.process_doc.description}\n\n"
163
+ f"[EXECUTION STATE]\n"
164
+ f"The following steps have already completed:\n{completed_info}\n"
165
+ f"[ACTIVE STEP TO EXECUTE]\n"
166
+ f"Step Index: {step_doc.index}\n"
167
+ f"Step Title: {step_doc.title}\n"
168
+ f"Instructions:\n{step_doc.instructions}\n\n"
169
+ f"[AVAILABLE CAPABILITIES]\n"
170
+ f"Referenced Skills:\n{skills_text}\n"
171
+ f"Referenced Tools:\n{tools_text}\n"
172
+ f"Referenced JSON Schemas:\n{schemas_text}\n"
173
+ f"INSTRUCTIONS:\n"
174
+ f"Examine the active step. Invoke available tools to retrieve data or execute calculations.\n"
175
+ f"Produce an output detailing the result, and save relevant outputs.\n"
176
+ f"When finished, output your summary."
177
+ )
178
+
179
+ # Get adapter
180
+ adapter = self.get_llm_adapter()
181
+
182
+ self.state_manager.log_system("Sending prompt to LLM adapter...")
183
+ response = adapter.complete(prompt, system_instruction=system_prompt, tools=tools_list_for_adapter)
184
+ self.state_manager.log_llm(prompt, response.text or str(response.tool_calls))
185
+
186
+ # Handle tool calls in a loop
187
+ while response.tool_calls:
188
+ for tc in response.tool_calls:
189
+ # Find tool definition
190
+ tool_ref = f"tool/{tc.name}"
191
+ tool_def = None
192
+ tool_dir = None
193
+ for ref, tool_data, t_dir in resolved_tools:
194
+ if ref == tool_ref or tool_data.get("name") == tc.name:
195
+ tool_def = tool_data
196
+ tool_dir = t_dir
197
+ break
198
+
199
+ if not tool_def:
200
+ result_str = f"Error: Tool {tc.name} is not available or referenced in this step."
201
+ self.state_manager.log_system(result_str)
202
+ else:
203
+ self.state_manager.log_system(f"Executing tool '{tc.name}' locally with args: {tc.arguments}")
204
+
205
+ record = ToolCallRecord(
206
+ tool_id=tc.name,
207
+ arguments=tc.arguments
208
+ )
209
+ step_state.tool_calls.append(record)
210
+ self.state_manager.save_state(state)
211
+
212
+ try:
213
+ # Run tool
214
+ result_str = self.execute_local_tool(tool_def, tool_dir, tc.arguments)
215
+
216
+ # Save evidence
217
+ evidence_filename = f"step{step_idx}_tool_{tc.name}_{uuid.uuid4().hex[:4]}.json"
218
+ evidence_rel_path = self.state_manager.save_evidence(evidence_filename, result_str)
219
+ record.output_file = str(evidence_rel_path)
220
+ self.state_manager.save_state(state)
221
+ except Exception as e:
222
+ result_str = f"Error executing tool {tc.name}: {e}"
223
+ self.state_manager.log_system(result_str)
224
+
225
+ # Append tool call result back to prompt history
226
+ prompt += f"\n\n[TOOL CALL RESULT]\nTool: {tc.name}\nResult:\n{result_str}"
227
+
228
+ # Query LLM again with results
229
+ self.state_manager.log_system("Sending tool results back to LLM adapter...")
230
+ response = adapter.complete(prompt, system_instruction=system_prompt, tools=tools_list_for_adapter)
231
+ self.state_manager.log_llm(prompt, response.text or str(response.tool_calls))
232
+
233
+ # Once LLM completes step execution
234
+ step_state.output = response.text
235
+
236
+ # Check if approval is required
237
+ if step_requires_approval(step_doc):
238
+ step_state.status = "waiting_for_approval"
239
+ state.status = "waiting_for_approval"
240
+ self.state_manager.log_system(f"Step {step_idx} completed but requires human approval/gate.")
241
+ self.state_manager.save_state(state)
242
+ break
243
+
244
+ step_state.status = "completed"
245
+ step_state.end_time = datetime.now(timezone.utc)
246
+ self.state_manager.log_system(f"Step {step_idx} completed successfully.")
247
+
248
+ state.current_step_index += 1
249
+ self.state_manager.save_state(state)
250
+
251
+ if target_step is not None and step_idx == target_step:
252
+ break
253
+
254
+ # Finalize run status
255
+ if state.current_step_index > len(state.steps):
256
+ state.status = "completed"
257
+ state.end_time = datetime.now(timezone.utc)
258
+ self.state_manager.save_state(state)
259
+ self.state_manager.log_system("Workflow execution completed successfully.")
260
+
261
+ return state
262
+
263
+ def execute_local_tool(self, tool_def: Dict[str, Any], tool_dir: Path, arguments: Dict[str, Any]) -> str:
264
+ entrypoint = tool_def.get("entrypoint", "main.py")
265
+
266
+ # Build cmd
267
+ if entrypoint.endswith(".py") and "python" not in entrypoint:
268
+ cmd = ["python", entrypoint]
269
+ elif entrypoint.endswith(".js") and "node" not in entrypoint:
270
+ cmd = ["node", entrypoint]
271
+ else:
272
+ cmd = entrypoint.split()
273
+
274
+ # Workspace environment
275
+ env = os.environ.copy()
276
+ if self.config.deploy and self.config.deploy.env:
277
+ for k, v in self.config.deploy.env.items():
278
+ env[k] = v
279
+
280
+ env["PDT_TOOL_ARGS"] = json.dumps(arguments)
281
+
282
+ res = subprocess.run(
283
+ cmd,
284
+ cwd=tool_dir,
285
+ input=json.dumps(arguments),
286
+ capture_output=True,
287
+ text=True,
288
+ env=env
289
+ )
290
+
291
+ if res.returncode != 0:
292
+ raise RuntimeError(f"Tool process exited with non-zero code {res.returncode}. Stderr: {res.stderr}")
293
+
294
+ return res.stdout
295
+
296
+ def get_llm_adapter(self) -> LLMAdapter:
297
+ provider = self.config.llm.provider
298
+ model = self.config.llm.model
299
+
300
+ if provider == "cli":
301
+ if not self.config.llm.cli:
302
+ raise ValueError("LLM provider is set to 'cli', but 'llm.cli' is not configured in pdt.yaml")
303
+ return CLIDelegatorAdapter(
304
+ command=self.config.llm.cli.command,
305
+ args=self.config.llm.cli.args
306
+ )
307
+ elif provider == "gemini":
308
+ return GeminiAdapter(model=model)
309
+ elif provider == "openai":
310
+ return OpenAIAdapter(model=model)
311
+ else:
312
+ raise ValueError(f"Unsupported LLM provider: {provider}")