pulse-coding-agent 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.
Files changed (104) hide show
  1. pulse/__init__.py +5 -0
  2. pulse/__main__.py +4 -0
  3. pulse/agent.py +270 -0
  4. pulse/agent_manager.py +335 -0
  5. pulse/audit.py +70 -0
  6. pulse/auth.py +670 -0
  7. pulse/ci/github_client.py +66 -0
  8. pulse/ci/runner.py +28 -0
  9. pulse/cli.py +1075 -0
  10. pulse/cli_ui.py +977 -0
  11. pulse/config.py +167 -0
  12. pulse/context.py +960 -0
  13. pulse/conversations/__init__.py +8 -0
  14. pulse/conversations/manager.py +312 -0
  15. pulse/core/agent.py +188 -0
  16. pulse/core/planner.py +105 -0
  17. pulse/core/protocols.py +37 -0
  18. pulse/edits.py +65 -0
  19. pulse/episodic.py +93 -0
  20. pulse/eval/__init__.py +8 -0
  21. pulse/eval/trajectory_logger.py +91 -0
  22. pulse/eval/verifier.py +133 -0
  23. pulse/execution/__init__.py +5 -0
  24. pulse/execution/remote_task.py +76 -0
  25. pulse/git.py +162 -0
  26. pulse/interactive.py +234 -0
  27. pulse/mcp/__init__.py +4 -0
  28. pulse/mcp/client.py +215 -0
  29. pulse/mcp/local_tools.py +105 -0
  30. pulse/memory.py +212 -0
  31. pulse/mutations.py +283 -0
  32. pulse/orchestration/__init__.py +3 -0
  33. pulse/orchestration/orchestrator.py +162 -0
  34. pulse/patch.py +129 -0
  35. pulse/planner/__init__.py +3 -0
  36. pulse/planner/dag_planner.py +85 -0
  37. pulse/planner/execution_loop.py +159 -0
  38. pulse/production.py +235 -0
  39. pulse/provider.py +59 -0
  40. pulse/provider_keys.py +278 -0
  41. pulse/providers/__init__.py +26 -0
  42. pulse/providers/anthropic.py +65 -0
  43. pulse/providers/base.py +251 -0
  44. pulse/providers/deepseek.py +10 -0
  45. pulse/providers/failover.py +32 -0
  46. pulse/providers/gemini.py +66 -0
  47. pulse/providers/groq.py +10 -0
  48. pulse/providers/manager.py +262 -0
  49. pulse/providers/openai.py +40 -0
  50. pulse/providers/openrouter.py +20 -0
  51. pulse/py.typed +1 -0
  52. pulse/reasoning.py +570 -0
  53. pulse/refactor/__init__.py +3 -0
  54. pulse/refactor/impact_analyzer.py +44 -0
  55. pulse/repository.py +209 -0
  56. pulse/rpc.py +249 -0
  57. pulse/rule_synthesizer.py +54 -0
  58. pulse/runtime.py +217 -0
  59. pulse/safety/__init__.py +3 -0
  60. pulse/safety/safety_manager.py +97 -0
  61. pulse/sandbox/SECURITY.md +57 -0
  62. pulse/sandbox/__init__.py +57 -0
  63. pulse/sandbox/api.py +594 -0
  64. pulse/sandbox/audit.py +153 -0
  65. pulse/sandbox/backend/__init__.py +7 -0
  66. pulse/sandbox/backend/base.py +72 -0
  67. pulse/sandbox/backend/docker.py +498 -0
  68. pulse/sandbox/backend/host.py +140 -0
  69. pulse/sandbox/backend/remote.py +224 -0
  70. pulse/sandbox/errors.py +106 -0
  71. pulse/sandbox/filesystem.py +476 -0
  72. pulse/sandbox/git_safe.py +50 -0
  73. pulse/sandbox/lifecycle.py +88 -0
  74. pulse/sandbox/network.py +205 -0
  75. pulse/sandbox/path_validator.py +280 -0
  76. pulse/sandbox/policy.py +209 -0
  77. pulse/sandbox/process.py +331 -0
  78. pulse/sandbox/project.py +158 -0
  79. pulse/sandbox/python_safe.py +62 -0
  80. pulse/sandbox/remote/__init__.py +1 -0
  81. pulse/sandbox/remote/client.py +389 -0
  82. pulse/sandbox/remote/models.py +167 -0
  83. pulse/sandbox/remote/protocol.py +65 -0
  84. pulse/sandbox/remote/server.py +984 -0
  85. pulse/sandbox/remote/worker.py +175 -0
  86. pulse/sandbox/resources.py +236 -0
  87. pulse/sandbox/secrets.py +241 -0
  88. pulse/session_manager.py +365 -0
  89. pulse/software_engineer.py +189 -0
  90. pulse/storage.py +140 -0
  91. pulse/streaming.py +385 -0
  92. pulse/subprocesses.py +79 -0
  93. pulse/task_manager.py +2005 -0
  94. pulse/telemetry/__init__.py +25 -0
  95. pulse/telemetry/cost_tracker.py +95 -0
  96. pulse/telemetry/logger.py +110 -0
  97. pulse/tool_policy.py +197 -0
  98. pulse/tool_registry.py +163 -0
  99. pulse/tools.py +372 -0
  100. pulse/verification.py +118 -0
  101. pulse_coding_agent-0.1.0.dist-info/METADATA +211 -0
  102. pulse_coding_agent-0.1.0.dist-info/RECORD +104 -0
  103. pulse_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  104. pulse_coding_agent-0.1.0.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,8 @@
1
+ """Conversation management for Pulse — multi-conversation SQLite store."""
2
+ from pulse.conversations.manager import (
3
+ Conversation,
4
+ ConversationManager,
5
+ ConversationTurn,
6
+ )
7
+
8
+ __all__ = ["Conversation", "ConversationManager", "ConversationTurn"]
@@ -0,0 +1,312 @@
1
+ """SQLite-backed Conversation Manager for Pulse.
2
+
3
+ Supports multiple named conversations, turn storage, auto-titling,
4
+ full-text search, export (Markdown / JSON), and active-conversation tracking.
5
+
6
+ Database location: <workspace>/.agent/conversations.sqlite3
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ import sqlite3
13
+ import uuid
14
+ from dataclasses import dataclass
15
+ from datetime import UTC, datetime
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from pulse.sandbox.secrets import SecretScrubber
20
+ from pulse.storage import migrate_database
21
+
22
+ CONVERSATION_SCHEMA_VERSION = 1
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Data models
26
+ # ---------------------------------------------------------------------------
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class ConversationTurn:
31
+ id: int
32
+ conv_id: str
33
+ role: str # "user" | "assistant" | "system"
34
+ content: str
35
+ created_at: str
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class Conversation:
40
+ id: str
41
+ title: str
42
+ created_at: str
43
+ updated_at: str
44
+ turn_count: int = 0
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # ConversationManager
49
+ # ---------------------------------------------------------------------------
50
+
51
+
52
+ class ConversationManager:
53
+ """Thread-safe (single-threaded) façade over a local SQLite conversation store."""
54
+
55
+ def __init__(self, workspace: Path, database_path: Path | None = None) -> None:
56
+ self.workspace = workspace.resolve()
57
+ self.database_path = (
58
+ database_path or self.workspace / ".agent" / "conversations.sqlite3"
59
+ )
60
+ self._scrubber = SecretScrubber()
61
+ self._ensure_schema()
62
+
63
+ # ------------------------------------------------------------------
64
+ # Schema bootstrap
65
+ # ------------------------------------------------------------------
66
+
67
+ def _connect(self) -> sqlite3.Connection:
68
+ self.database_path.parent.mkdir(parents=True, exist_ok=True)
69
+ conn = sqlite3.connect(self.database_path)
70
+ conn.execute("PRAGMA journal_mode=WAL")
71
+ conn.execute("PRAGMA foreign_keys=ON")
72
+ return conn
73
+
74
+ def _ensure_schema(self) -> None:
75
+ def migration(conn: sqlite3.Connection, _current: int) -> None:
76
+ conn.execute("""CREATE TABLE IF NOT EXISTS conversations (
77
+ id TEXT PRIMARY KEY,
78
+ title TEXT NOT NULL,
79
+ created_at TEXT NOT NULL,
80
+ updated_at TEXT NOT NULL
81
+ )""")
82
+ conn.execute("""CREATE TABLE IF NOT EXISTS turns (
83
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
84
+ conv_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
85
+ role TEXT NOT NULL,
86
+ content TEXT NOT NULL,
87
+ created_at TEXT NOT NULL
88
+ )""")
89
+ conn.execute("""CREATE TABLE IF NOT EXISTS meta (
90
+ key TEXT PRIMARY KEY,
91
+ value TEXT NOT NULL
92
+ )""")
93
+ conn.execute("CREATE INDEX IF NOT EXISTS turns_conv_idx ON turns(conv_id)")
94
+
95
+ migrate_database(self.database_path, CONVERSATION_SCHEMA_VERSION, migration)
96
+
97
+ # ------------------------------------------------------------------
98
+ # Conversation CRUD
99
+ # ------------------------------------------------------------------
100
+
101
+ def create(self, title: str | None = None) -> Conversation:
102
+ """Create a new conversation and set it as the active one."""
103
+ conv_id = str(uuid.uuid4())
104
+ now = datetime.now(UTC).isoformat()
105
+ display_title = self._scrubber.redact(title or "New Conversation")
106
+ with self._connect() as conn:
107
+ conn.execute(
108
+ "INSERT INTO conversations(id, title, created_at, updated_at) VALUES (?, ?, ?, ?)",
109
+ (conv_id, display_title, now, now),
110
+ )
111
+ self.switch(conv_id)
112
+ return Conversation(id=conv_id, title=display_title, created_at=now, updated_at=now, turn_count=0)
113
+
114
+ def auto_title(self, conv_id: str, first_message: str) -> Conversation:
115
+ """Generate a tidy title from the first user message (≤ 60 chars)."""
116
+ # Strip punctuation/whitespace, take first 60 chars, capitalize
117
+ cleaned = re.sub(r"\s+", " ", self._scrubber.redact(first_message).strip())
118
+ title = cleaned[:60].rstrip(" ,.:;!?")
119
+ if len(cleaned) > 60:
120
+ title += "…"
121
+ return self.rename(conv_id, title or "Conversation")
122
+
123
+ def get(self, conv_id: str) -> Conversation | None:
124
+ with self._connect() as conn:
125
+ row = conn.execute(
126
+ "SELECT id, title, created_at, updated_at FROM conversations WHERE id = ?",
127
+ (conv_id,),
128
+ ).fetchone()
129
+ if row is None:
130
+ return None
131
+ count = conn.execute(
132
+ "SELECT COUNT(*) FROM turns WHERE conv_id = ?", (conv_id,)
133
+ ).fetchone()[0]
134
+ return Conversation(id=row[0], title=row[1], created_at=row[2], updated_at=row[3], turn_count=count)
135
+
136
+ def list_all(self) -> list[Conversation]:
137
+ """Return all conversations ordered by most recently updated."""
138
+ with self._connect() as conn:
139
+ rows = conn.execute(
140
+ "SELECT c.id, c.title, c.created_at, c.updated_at, COUNT(t.id) AS turn_count "
141
+ "FROM conversations c LEFT JOIN turns t ON t.conv_id = c.id "
142
+ "GROUP BY c.id ORDER BY c.updated_at DESC"
143
+ ).fetchall()
144
+ return [Conversation(id=r[0], title=r[1], created_at=r[2], updated_at=r[3], turn_count=r[4]) for r in rows]
145
+
146
+ def rename(self, conv_id: str, new_title: str) -> Conversation:
147
+ new_title = self._scrubber.redact(new_title).strip() or "Untitled"
148
+ now = datetime.now(UTC).isoformat()
149
+ with self._connect() as conn:
150
+ conn.execute(
151
+ "UPDATE conversations SET title = ?, updated_at = ? WHERE id = ?",
152
+ (new_title, now, conv_id),
153
+ )
154
+ conv = self.get(conv_id)
155
+ if conv is None:
156
+ raise ValueError(f"Conversation {conv_id!r} not found.")
157
+ return conv
158
+
159
+ def delete(self, conv_id: str) -> None:
160
+ """Delete a conversation and all its turns. Clears active if it was active."""
161
+ with self._connect() as conn:
162
+ conn.execute("DELETE FROM conversations WHERE id = ?", (conv_id,))
163
+ # If active conversation was the deleted one, clear it
164
+ if self._get_meta("active_conv_id") == conv_id:
165
+ self._set_meta("active_conv_id", "")
166
+
167
+ def switch(self, conv_id: str) -> Conversation:
168
+ """Mark a conversation as the active one. Returns the conversation."""
169
+ conv = self.get(conv_id)
170
+ if conv is None:
171
+ raise ValueError(f"Conversation {conv_id!r} not found.")
172
+ self._set_meta("active_conv_id", conv_id)
173
+ return conv
174
+
175
+ def get_active(self) -> Conversation | None:
176
+ """Return the currently active conversation, or None if unset/deleted."""
177
+ conv_id = self._get_meta("active_conv_id")
178
+ if not conv_id:
179
+ return None
180
+ return self.get(conv_id)
181
+
182
+ # ------------------------------------------------------------------
183
+ # Turn management
184
+ # ------------------------------------------------------------------
185
+
186
+ def add_turn(self, conv_id: str, role: str, content: str) -> ConversationTurn:
187
+ content = self._scrubber.redact(content)
188
+ now = datetime.now(UTC).isoformat()
189
+ with self._connect() as conn:
190
+ cursor = conn.execute(
191
+ "INSERT INTO turns(conv_id, role, content, created_at) VALUES (?, ?, ?, ?)",
192
+ (conv_id, role, content, now),
193
+ )
194
+ turn_id = cursor.lastrowid
195
+ # Bump updated_at on the conversation
196
+ conn.execute(
197
+ "UPDATE conversations SET updated_at = ? WHERE id = ?", (now, conv_id)
198
+ )
199
+ return ConversationTurn(id=turn_id, conv_id=conv_id, role=role, content=content, created_at=now)
200
+
201
+ def get_turns(self, conv_id: str) -> list[ConversationTurn]:
202
+ with self._connect() as conn:
203
+ rows = conn.execute(
204
+ "SELECT id, conv_id, role, content, created_at FROM turns WHERE conv_id = ? ORDER BY id ASC",
205
+ (conv_id,),
206
+ ).fetchall()
207
+ return [ConversationTurn(id=r[0], conv_id=r[1], role=r[2], content=r[3], created_at=r[4]) for r in rows]
208
+
209
+ # ------------------------------------------------------------------
210
+ # Search
211
+ # ------------------------------------------------------------------
212
+
213
+ def search(self, query: str) -> list[Conversation]:
214
+ """Full-text LIKE search on conversation titles and turn content."""
215
+ if not query.strip():
216
+ return self.list_all()
217
+ pattern = f"%{query.strip()}%"
218
+ with self._connect() as conn:
219
+ rows = conn.execute(
220
+ """
221
+ SELECT DISTINCT c.id, c.title, c.created_at, c.updated_at,
222
+ (SELECT COUNT(*) FROM turns t2 WHERE t2.conv_id = c.id) AS turn_count
223
+ FROM conversations c
224
+ LEFT JOIN turns t ON t.conv_id = c.id
225
+ WHERE c.title LIKE ? OR t.content LIKE ?
226
+ ORDER BY c.updated_at DESC
227
+ """,
228
+ (pattern, pattern),
229
+ ).fetchall()
230
+ return [Conversation(id=r[0], title=r[1], created_at=r[2], updated_at=r[3], turn_count=r[4]) for r in rows]
231
+
232
+ # ------------------------------------------------------------------
233
+ # Export
234
+ # ------------------------------------------------------------------
235
+
236
+ def export(
237
+ self,
238
+ conv_id: str,
239
+ output_path: Path | None = None,
240
+ fmt: str = "md",
241
+ ) -> Path:
242
+ """Export a conversation to Markdown or JSON.
243
+
244
+ Args:
245
+ conv_id: The conversation UUID to export.
246
+ output_path: Explicit file path, or auto-generated in workspace root.
247
+ fmt: "md" (default) or "json".
248
+ """
249
+ conv = self.get(conv_id)
250
+ if conv is None:
251
+ raise ValueError(f"Conversation {conv_id!r} not found.")
252
+ turns = self.get_turns(conv_id)
253
+
254
+ ext = "md" if fmt == "md" else "json"
255
+ if output_path is None:
256
+ safe_title = re.sub(r"[^\w\-]", "_", conv.title)[:40]
257
+ output_path = self.workspace / f"{safe_title}_{conv_id[:8]}.{ext}"
258
+ else:
259
+ output_path = Path(output_path)
260
+
261
+ output_path.parent.mkdir(parents=True, exist_ok=True)
262
+
263
+ if fmt == "json":
264
+ data: dict[str, Any] = {
265
+ "id": conv.id,
266
+ "title": conv.title,
267
+ "created_at": conv.created_at,
268
+ "updated_at": conv.updated_at,
269
+ "turns": [
270
+ {"role": t.role, "content": t.content, "timestamp": t.created_at}
271
+ for t in turns
272
+ ],
273
+ }
274
+ output_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
275
+ else:
276
+ lines: list[str] = [
277
+ f"# {conv.title}",
278
+ "",
279
+ f"> **Created:** {conv.created_at} ",
280
+ f"> **Updated:** {conv.updated_at}",
281
+ "",
282
+ "---",
283
+ "",
284
+ ]
285
+ for turn in turns:
286
+ label = "**You**" if turn.role == "user" else "**Pulse**"
287
+ lines.append(f"### {label} ")
288
+ lines.append(f"*{turn.created_at}*")
289
+ lines.append("")
290
+ lines.append(turn.content)
291
+ lines.append("")
292
+ lines.append("---")
293
+ lines.append("")
294
+ output_path.write_text("\n".join(lines), encoding="utf-8")
295
+
296
+ return output_path
297
+
298
+ # ------------------------------------------------------------------
299
+ # Internal helpers
300
+ # ------------------------------------------------------------------
301
+
302
+ def _get_meta(self, key: str) -> str:
303
+ with self._connect() as conn:
304
+ row = conn.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone()
305
+ return str(row[0]) if row else ""
306
+
307
+ def _set_meta(self, key: str, value: str) -> None:
308
+ with self._connect() as conn:
309
+ conn.execute(
310
+ "INSERT INTO meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
311
+ (key, value),
312
+ )
pulse/core/agent.py ADDED
@@ -0,0 +1,188 @@
1
+ """CLI- and provider-independent orchestration for Pulse requests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections import defaultdict
7
+ from collections.abc import AsyncGenerator, Sequence
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Protocol
10
+ from uuid import uuid4
11
+
12
+ from pulse.core.planner import (
13
+ ExecutionPlan,
14
+ PlanAction,
15
+ PlanCondition,
16
+ PlanGenerator,
17
+ PlanningRequest,
18
+ RequestPlanner,
19
+ )
20
+ from pulse.core.protocols import LLMProvider, StreamChunk
21
+ from pulse.tool_registry import ToolInvocation, ToolRegistry, ToolResult
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class AgentRequest:
26
+ message: str
27
+ conversation_id: str = "default"
28
+ context: Sequence[str] = ()
29
+ metadata: dict[str, Any] = field(default_factory=dict)
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class AgentResponse:
34
+ content: str
35
+ conversation_id: str
36
+ request_id: str
37
+ tool_name: str | None = None
38
+ metadata: dict[str, Any] = field(default_factory=dict)
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class ConversationMessage:
43
+ role: str
44
+ content: str
45
+
46
+
47
+ class ConversationStore(Protocol):
48
+ async def read(self, conversation_id: str) -> list[ConversationMessage]: ...
49
+
50
+ async def append(self, conversation_id: str, message: ConversationMessage) -> None: ...
51
+
52
+
53
+ class ContextSource(Protocol):
54
+ async def context_for(self, request: AgentRequest) -> Sequence[str]: ...
55
+
56
+
57
+ class InMemoryConversationStore:
58
+ """Safe default history store; replace it with persistent memory via DI."""
59
+
60
+ def __init__(self) -> None:
61
+ self._messages: defaultdict[str, list[ConversationMessage]] = defaultdict(list)
62
+ self._lock = asyncio.Lock()
63
+
64
+ async def read(self, conversation_id: str) -> list[ConversationMessage]:
65
+ async with self._lock:
66
+ return list(self._messages[conversation_id])
67
+
68
+ async def append(self, conversation_id: str, message: ConversationMessage) -> None:
69
+ async with self._lock:
70
+ self._messages[conversation_id].append(message)
71
+
72
+
73
+ class Agent:
74
+ """Coordinates request analysis, optional tools, memory, and model streaming.
75
+
76
+ The Agent depends only on protocols. CLI adapters supply requests, providers
77
+ implement ``LLMProvider``, and future memory/planning/tool systems can be
78
+ injected without changing this public API.
79
+ """
80
+
81
+ def __init__(
82
+ self,
83
+ provider: LLMProvider,
84
+ *,
85
+ system_prompt: str,
86
+ conversation_store: ConversationStore | None = None,
87
+ context_source: ContextSource | None = None,
88
+ planner: PlanGenerator | None = None,
89
+ tool_registry: ToolRegistry | None = None,
90
+ ) -> None:
91
+ self._provider = provider
92
+ self._system_prompt = system_prompt
93
+ self._store = conversation_store or InMemoryConversationStore()
94
+ self._context_source = context_source
95
+ self._planner = planner or RequestPlanner()
96
+ self._tool_registry = tool_registry or ToolRegistry()
97
+
98
+ async def respond(self, request: AgentRequest) -> AgentResponse:
99
+ request_id = str(request.metadata.get("request_id") or uuid4())
100
+ request = AgentRequest(
101
+ message=request.message,
102
+ conversation_id=request.conversation_id,
103
+ context=request.context,
104
+ metadata={**request.metadata, "request_id": request_id},
105
+ )
106
+ chunks: list[str] = []
107
+ response_metadata: dict[str, Any] = {}
108
+ tool_name: str | None = None
109
+ async for chunk in self.stream(request):
110
+ chunks.append(chunk.content)
111
+ response_metadata.update(chunk.metadata)
112
+ tool_name = str(chunk.metadata.get("tool_name")) if chunk.metadata.get("tool_name") else tool_name
113
+ return AgentResponse(
114
+ content="".join(chunks),
115
+ conversation_id=request.conversation_id,
116
+ request_id=request_id,
117
+ tool_name=tool_name,
118
+ metadata=response_metadata,
119
+ )
120
+
121
+ async def stream(self, request: AgentRequest) -> AsyncGenerator[StreamChunk, None]:
122
+ """Analyze and execute one request, yielding the final answer incrementally."""
123
+ if not request.message.strip():
124
+ raise ValueError("Agent requests must include a message.")
125
+
126
+ request_id = str(request.metadata.get("request_id") or uuid4())
127
+ normalized_request = AgentRequest(
128
+ message=request.message,
129
+ conversation_id=request.conversation_id,
130
+ context=request.context,
131
+ metadata={**request.metadata, "request_id": request_id},
132
+ )
133
+ await self._store.append(normalized_request.conversation_id, ConversationMessage("user", normalized_request.message))
134
+ plan = await self._planner.plan(PlanningRequest(normalized_request.message, dict(normalized_request.metadata)))
135
+ context = list(normalized_request.context)
136
+ tool_result: ToolResult | None = None
137
+ tool = None
138
+
139
+ for step in plan.steps:
140
+ if step.action is PlanAction.CONTEXT:
141
+ if self._context_source:
142
+ context.extend(await self._context_source.context_for(normalized_request))
143
+ elif step.action is PlanAction.TOOL:
144
+ invocation = ToolInvocation(
145
+ name=normalized_request.metadata.get("tool_name"),
146
+ message=normalized_request.message,
147
+ metadata=normalized_request.metadata,
148
+ )
149
+ tool = self._tool_registry.match(invocation)
150
+ if tool and self._condition_allows(step.condition, tool_result, tool_available=True):
151
+ tool_result = await self._tool_registry.execute(invocation)
152
+ if tool_result and tool_result.terminal:
153
+ await self._store.append(normalized_request.conversation_id, ConversationMessage("assistant", tool_result.content))
154
+ yield StreamChunk(tool_result.content, {"request_id": request_id, "tool_name": tool.name, "plan": plan, **tool_result.metadata})
155
+ return
156
+ elif step.action is PlanAction.LLM and self._condition_allows(step.condition, tool_result):
157
+ messages = await self._messages_for(normalized_request, context, tool_result, plan)
158
+ output: list[str] = []
159
+ async for chunk in self._provider.generate_stream(messages):
160
+ output.append(chunk.content)
161
+ yield StreamChunk(chunk.content, {"request_id": request_id, "tool_name": tool.name if tool else None, "plan": plan, **chunk.metadata})
162
+ await self._store.append(normalized_request.conversation_id, ConversationMessage("assistant", "".join(output)))
163
+ return
164
+
165
+ raise RuntimeError("Execution plan did not contain an executable response step.")
166
+
167
+ @staticmethod
168
+ def _condition_allows(
169
+ condition: PlanCondition, tool_result: ToolResult | None, *, tool_available: bool = False
170
+ ) -> bool:
171
+ if condition is PlanCondition.TOOL_AVAILABLE:
172
+ return tool_available
173
+ if condition is PlanCondition.NO_TERMINAL_TOOL_RESULT:
174
+ return not (tool_result and tool_result.terminal)
175
+ return True
176
+
177
+ async def _messages_for(self, request: AgentRequest, context: list[str], tool_result: ToolResult | None, plan: ExecutionPlan) -> list[dict[str, str]]:
178
+ history = await self._store.read(request.conversation_id)
179
+
180
+ messages = [{"role": "system", "content": self._system_prompt}]
181
+ messages.extend({"role": item.role, "content": item.content} for item in history)
182
+ if context:
183
+ messages.append({"role": "system", "content": "Approved context:\n" + "\n\n".join(context)})
184
+ if plan:
185
+ messages.append({"role": "system", "content": "Execution plan:\n" + "\n".join(plan.render())})
186
+ if tool_result:
187
+ messages.append({"role": "tool", "content": tool_result.content})
188
+ return messages
pulse/core/planner.py ADDED
@@ -0,0 +1,105 @@
1
+ """Provider- and tool-independent request planning primitives for Pulse."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import StrEnum
7
+ from typing import Any, Protocol
8
+
9
+
10
+ class PlanAction(StrEnum):
11
+ """Work the agent runtime can perform for a plan step."""
12
+
13
+ CONTEXT = "context"
14
+ TOOL = "tool"
15
+ LLM = "llm"
16
+
17
+
18
+ class PlanCondition(StrEnum):
19
+ """Runtime conditions used to select a branch without coupling to tools."""
20
+
21
+ ALWAYS = "always"
22
+ TOOL_AVAILABLE = "tool_available"
23
+ NO_TERMINAL_TOOL_RESULT = "no_terminal_tool_result"
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class PlanningRequest:
28
+ """The small request shape planners need; adapters keep richer request data."""
29
+
30
+ message: str
31
+ metadata: dict[str, Any] = field(default_factory=dict)
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class PlanStep:
36
+ """One declarative unit of work in an execution plan."""
37
+
38
+ id: str
39
+ action: PlanAction
40
+ description: str
41
+ condition: PlanCondition = PlanCondition.ALWAYS
42
+ depends_on: tuple[str, ...] = ()
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class ExecutionPlan:
47
+ """An immutable, validated plan suitable for audit and future persistence."""
48
+
49
+ goal: str
50
+ steps: tuple[PlanStep, ...]
51
+
52
+ def __post_init__(self) -> None:
53
+ if not self.goal.strip() or not self.steps:
54
+ raise ValueError("Plans require a goal and at least one step.")
55
+ completed: set[str] = set()
56
+ for step in self.steps:
57
+ if not step.id or step.id in completed:
58
+ raise ValueError("Plan step IDs must be unique and non-empty.")
59
+ if not step.description.strip() or not set(step.depends_on).issubset(completed):
60
+ raise ValueError("Plan steps may only depend on earlier steps.")
61
+ completed.add(step.id)
62
+
63
+ def render(self) -> tuple[str, ...]:
64
+ """Human-readable steps for model context and audit records."""
65
+ return tuple(f"{number}. {step.description}" for number, step in enumerate(self.steps, start=1))
66
+
67
+
68
+ class PlanGenerator(Protocol):
69
+ """Extension point for policy, model, or workflow-specific planners."""
70
+
71
+ async def plan(self, request: PlanningRequest) -> ExecutionPlan: ...
72
+
73
+
74
+ class RequestPlanner:
75
+ """Default deterministic planner for safe, inspectable request execution.
76
+
77
+ It intentionally knows nothing about providers or registered tools. The
78
+ runtime evaluates the two conditional branches when it executes the plan.
79
+ Custom planners can replace this class through dependency injection.
80
+ """
81
+
82
+ async def plan(self, request: PlanningRequest) -> ExecutionPlan:
83
+ goal = request.message.strip()
84
+ if not goal:
85
+ raise ValueError("Planning requests must include a message.")
86
+ requested_tool = request.metadata.get("tool_name")
87
+ tool_description = (
88
+ f"Run requested tool '{requested_tool}' if it is available."
89
+ if requested_tool
90
+ else "Run a matching local tool if one can handle this request."
91
+ )
92
+ return ExecutionPlan(
93
+ goal=goal,
94
+ steps=(
95
+ PlanStep("collect-context", PlanAction.CONTEXT, "Collect approved context required for the request."),
96
+ PlanStep("route-tool", PlanAction.TOOL, tool_description, PlanCondition.TOOL_AVAILABLE, ("collect-context",)),
97
+ PlanStep(
98
+ "generate-response",
99
+ PlanAction.LLM,
100
+ "Generate the final response from the gathered context and tool results.",
101
+ PlanCondition.NO_TERMINAL_TOOL_RESULT,
102
+ ("route-tool",),
103
+ ),
104
+ ),
105
+ )
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncGenerator
4
+ from dataclasses import dataclass, field
5
+ from typing import Any, Protocol
6
+
7
+ from pulse.config import ModelConfig
8
+
9
+
10
+ @dataclass(slots=True)
11
+ class StreamChunk:
12
+ """A single streamed token or chunk emitted by an LLM provider."""
13
+
14
+ content: str
15
+ metadata: dict[str, Any] = field(default_factory=dict)
16
+
17
+
18
+ class LLMProvider(Protocol):
19
+ """Unified structural contract for any AI engine plugged into Pulse."""
20
+
21
+ config: ModelConfig
22
+
23
+ @property
24
+ def is_configured(self) -> bool:
25
+ """Whether the provider has enough credentials to call the model."""
26
+ ...
27
+
28
+ async def generate_stream(
29
+ self,
30
+ messages: list[dict[str, Any]],
31
+ temperature: float = 0.2,
32
+ ) -> AsyncGenerator[StreamChunk, None]:
33
+ """Yield streamed response chunks for the provided conversation messages."""
34
+
35
+ def chat(self, messages: list[Any], temperature: float = 0.2) -> str:
36
+ """Return a complete response for the provided conversation messages."""
37
+ ...