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.
- pulse/__init__.py +5 -0
- pulse/__main__.py +4 -0
- pulse/agent.py +270 -0
- pulse/agent_manager.py +335 -0
- pulse/audit.py +70 -0
- pulse/auth.py +670 -0
- pulse/ci/github_client.py +66 -0
- pulse/ci/runner.py +28 -0
- pulse/cli.py +1075 -0
- pulse/cli_ui.py +977 -0
- pulse/config.py +167 -0
- pulse/context.py +960 -0
- pulse/conversations/__init__.py +8 -0
- pulse/conversations/manager.py +312 -0
- pulse/core/agent.py +188 -0
- pulse/core/planner.py +105 -0
- pulse/core/protocols.py +37 -0
- pulse/edits.py +65 -0
- pulse/episodic.py +93 -0
- pulse/eval/__init__.py +8 -0
- pulse/eval/trajectory_logger.py +91 -0
- pulse/eval/verifier.py +133 -0
- pulse/execution/__init__.py +5 -0
- pulse/execution/remote_task.py +76 -0
- pulse/git.py +162 -0
- pulse/interactive.py +234 -0
- pulse/mcp/__init__.py +4 -0
- pulse/mcp/client.py +215 -0
- pulse/mcp/local_tools.py +105 -0
- pulse/memory.py +212 -0
- pulse/mutations.py +283 -0
- pulse/orchestration/__init__.py +3 -0
- pulse/orchestration/orchestrator.py +162 -0
- pulse/patch.py +129 -0
- pulse/planner/__init__.py +3 -0
- pulse/planner/dag_planner.py +85 -0
- pulse/planner/execution_loop.py +159 -0
- pulse/production.py +235 -0
- pulse/provider.py +59 -0
- pulse/provider_keys.py +278 -0
- pulse/providers/__init__.py +26 -0
- pulse/providers/anthropic.py +65 -0
- pulse/providers/base.py +251 -0
- pulse/providers/deepseek.py +10 -0
- pulse/providers/failover.py +32 -0
- pulse/providers/gemini.py +66 -0
- pulse/providers/groq.py +10 -0
- pulse/providers/manager.py +262 -0
- pulse/providers/openai.py +40 -0
- pulse/providers/openrouter.py +20 -0
- pulse/py.typed +1 -0
- pulse/reasoning.py +570 -0
- pulse/refactor/__init__.py +3 -0
- pulse/refactor/impact_analyzer.py +44 -0
- pulse/repository.py +209 -0
- pulse/rpc.py +249 -0
- pulse/rule_synthesizer.py +54 -0
- pulse/runtime.py +217 -0
- pulse/safety/__init__.py +3 -0
- pulse/safety/safety_manager.py +97 -0
- pulse/sandbox/SECURITY.md +57 -0
- pulse/sandbox/__init__.py +57 -0
- pulse/sandbox/api.py +594 -0
- pulse/sandbox/audit.py +153 -0
- pulse/sandbox/backend/__init__.py +7 -0
- pulse/sandbox/backend/base.py +72 -0
- pulse/sandbox/backend/docker.py +498 -0
- pulse/sandbox/backend/host.py +140 -0
- pulse/sandbox/backend/remote.py +224 -0
- pulse/sandbox/errors.py +106 -0
- pulse/sandbox/filesystem.py +476 -0
- pulse/sandbox/git_safe.py +50 -0
- pulse/sandbox/lifecycle.py +88 -0
- pulse/sandbox/network.py +205 -0
- pulse/sandbox/path_validator.py +280 -0
- pulse/sandbox/policy.py +209 -0
- pulse/sandbox/process.py +331 -0
- pulse/sandbox/project.py +158 -0
- pulse/sandbox/python_safe.py +62 -0
- pulse/sandbox/remote/__init__.py +1 -0
- pulse/sandbox/remote/client.py +389 -0
- pulse/sandbox/remote/models.py +167 -0
- pulse/sandbox/remote/protocol.py +65 -0
- pulse/sandbox/remote/server.py +984 -0
- pulse/sandbox/remote/worker.py +175 -0
- pulse/sandbox/resources.py +236 -0
- pulse/sandbox/secrets.py +241 -0
- pulse/session_manager.py +365 -0
- pulse/software_engineer.py +189 -0
- pulse/storage.py +140 -0
- pulse/streaming.py +385 -0
- pulse/subprocesses.py +79 -0
- pulse/task_manager.py +2005 -0
- pulse/telemetry/__init__.py +25 -0
- pulse/telemetry/cost_tracker.py +95 -0
- pulse/telemetry/logger.py +110 -0
- pulse/tool_policy.py +197 -0
- pulse/tool_registry.py +163 -0
- pulse/tools.py +372 -0
- pulse/verification.py +118 -0
- pulse_coding_agent-0.1.0.dist-info/METADATA +211 -0
- pulse_coding_agent-0.1.0.dist-info/RECORD +104 -0
- pulse_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- pulse_coding_agent-0.1.0.dist-info/entry_points.txt +4 -0
|
@@ -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
|
+
)
|
pulse/core/protocols.py
ADDED
|
@@ -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
|
+
...
|