sallm-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 (49) hide show
  1. sallm/__init__.py +38 -0
  2. sallm/agent.py +412 -0
  3. sallm/cli/__init__.py +6 -0
  4. sallm/cli/__main__.py +4 -0
  5. sallm/cli/chat.py +357 -0
  6. sallm/cli/display.py +242 -0
  7. sallm/cli/optimize_cmd.py +117 -0
  8. sallm/context.py +128 -0
  9. sallm/control.py +172 -0
  10. sallm/extract_queue.py +165 -0
  11. sallm/legacy_ask.py +164 -0
  12. sallm/llm.py +44 -0
  13. sallm/memory/__init__.py +52 -0
  14. sallm/memory/chunking.py +56 -0
  15. sallm/memory/config.py +85 -0
  16. sallm/memory/embedding.py +44 -0
  17. sallm/memory/gates.py +48 -0
  18. sallm/memory/index.py +131 -0
  19. sallm/memory/lance.py +204 -0
  20. sallm/memory/query.py +104 -0
  21. sallm/memory/retrieval.py +28 -0
  22. sallm/memory/types.py +69 -0
  23. sallm/messages.py +14 -0
  24. sallm/metrics.py +46 -0
  25. sallm/models.py +79 -0
  26. sallm/optimization/__init__.py +21 -0
  27. sallm/optimization/artifacts.py +58 -0
  28. sallm/optimization/dataset.py +43 -0
  29. sallm/optimization/metrics.py +42 -0
  30. sallm/optimization/search.py +154 -0
  31. sallm/profiles/gemma4-e4b-v1.json +22 -0
  32. sallm/prom.py +430 -0
  33. sallm/prompt.py +177 -0
  34. sallm/receipt.py +176 -0
  35. sallm/skills.py +70 -0
  36. sallm/state/__init__.py +17 -0
  37. sallm/state/models.py +98 -0
  38. sallm/state/repository.py +370 -0
  39. sallm/tools/__init__.py +109 -0
  40. sallm/tools/calc.py +152 -0
  41. sallm/tools/dig.py +86 -0
  42. sallm/tools/echo.py +35 -0
  43. sallm/tools/runner.py +229 -0
  44. sallm/trace.py +550 -0
  45. sallm/turn.py +230 -0
  46. sallm_agent-0.1.0.dist-info/METADATA +155 -0
  47. sallm_agent-0.1.0.dist-info/RECORD +49 -0
  48. sallm_agent-0.1.0.dist-info/WHEEL +4 -0
  49. sallm_agent-0.1.0.dist-info/entry_points.txt +5 -0
sallm/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """Public package exports — agent + prompt + memory/state types."""
2
+
3
+ from .agent import Agent
4
+ from .memory import (
5
+ LanceVectorStore,
6
+ RetrievalConfig,
7
+ VectorHit,
8
+ VectorQuery,
9
+ VectorRecord,
10
+ VectorStore,
11
+ )
12
+ from .messages import DEFAULT_API_BASE, DEFAULT_MODEL
13
+ from .models import EmbeddingProfile, ModelProfile
14
+ from .prompt import CompiledProfile, Prompt
15
+ from .receipt import ContextReceipt
16
+ from .skills import Skill, SkillRegistry
17
+ from .tools import CliTool, ToolResult
18
+
19
+ __all__ = [
20
+ "Agent",
21
+ "CliTool",
22
+ "CompiledProfile",
23
+ "ContextReceipt",
24
+ "EmbeddingProfile",
25
+ "LanceVectorStore",
26
+ "ModelProfile",
27
+ "Prompt",
28
+ "RetrievalConfig",
29
+ "Skill",
30
+ "SkillRegistry",
31
+ "ToolResult",
32
+ "VectorHit",
33
+ "VectorQuery",
34
+ "VectorRecord",
35
+ "VectorStore",
36
+ "DEFAULT_MODEL",
37
+ "DEFAULT_API_BASE",
38
+ ]
sallm/agent.py ADDED
@@ -0,0 +1,412 @@
1
+ """Agent facade — durable session wiring + ask() entrypoint.
2
+
3
+ Heavy turn logic lives in turn.py. Without state_path, behaves like the
4
+ classic in-memory ReAct agent (existing tests keep working).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import replace
10
+ from pathlib import Path
11
+
12
+ from . import metrics as metrics_mod
13
+ from .control import Controller, MemoryExtractor
14
+ from .extract_queue import ExtractScheduler, normalize_extract_mode, should_miss_flush
15
+ from .legacy_ask import ask_legacy
16
+ from .memory import (
17
+ DefaultQueryComposer,
18
+ HeuristicMemoryGate,
19
+ HyDE,
20
+ LanceVectorStore,
21
+ LiteLLMEmbedder,
22
+ MemoryIndexer,
23
+ PassThroughGate,
24
+ RetrievalConfig,
25
+ TokenChunker,
26
+ resolve_retrieval_config,
27
+ retrieve_hits,
28
+ )
29
+ from .messages import DEFAULT_API_BASE, DEFAULT_MODEL, assistant, system, user
30
+ from .models import (
31
+ EmbeddingProfile,
32
+ ModelProfile,
33
+ resolve_embedding_profile,
34
+ resolve_model_profile,
35
+ )
36
+ from .prompt import CompiledProfile, Prompt
37
+ from .receipt import ContextReceipt
38
+ from .skills import SkillRegistry
39
+ from .state import SessionRepository
40
+ from .tools import normalize_registry, tool_descriptions
41
+ from .turn import TurnRunner, apply_stack_decision
42
+
43
+
44
+ class Agent:
45
+ def __init__(
46
+ self,
47
+ model=None,
48
+ api_base=None,
49
+ tools=None,
50
+ system=None,
51
+ max_steps=5,
52
+ multi_step=True,
53
+ trace=None,
54
+ context=None,
55
+ *,
56
+ session_id: str | None = None,
57
+ state_path: str | Path | None = None,
58
+ vector_path: str | Path | None = None,
59
+ vector_store=None,
60
+ profile: ModelProfile | None = None,
61
+ embedding_profile: EmbeddingProfile | None = None,
62
+ skills: SkillRegistry | None = None,
63
+ compiled_profile: CompiledProfile | None = None,
64
+ retrieval: RetrievalConfig | None = None,
65
+ retrieval_mode: str | None = None,
66
+ search_mode: str | None = None,
67
+ memory_gate: bool | None = None,
68
+ extract_mode: str | None = None,
69
+ embedder=None,
70
+ ):
71
+ self.model = model or DEFAULT_MODEL
72
+ self.api_base = api_base or DEFAULT_API_BASE
73
+ self.tools = normalize_registry(tools)
74
+ self.max_steps = max_steps
75
+ self.multi_step = multi_step
76
+ self.system = system
77
+ self.trace = trace
78
+ self.context = context
79
+ self.profile = profile or resolve_model_profile(
80
+ self.model, api_base=self.api_base
81
+ )
82
+ if api_base:
83
+ self.profile = replace(
84
+ self.profile, api_base=api_base, model=self.model
85
+ )
86
+ self.embedding_profile = embedding_profile or resolve_embedding_profile()
87
+ self.skills = skills or SkillRegistry()
88
+ self.compiled_profile = compiled_profile
89
+ self.extract_mode = normalize_extract_mode(extract_mode)
90
+
91
+ if retrieval is not None:
92
+ self.retrieval = retrieval
93
+ else:
94
+ self.retrieval = resolve_retrieval_config(
95
+ retrieval_query=retrieval_mode or "instruct",
96
+ search_mode=search_mode or "dense",
97
+ memory_gate=True if memory_gate is None else memory_gate,
98
+ )
99
+ # Legacy alias for banners / older callers.
100
+ self.retrieval_mode = self.retrieval.label
101
+
102
+ self.session_id = session_id or "default"
103
+ self.last_prompt = None
104
+ self.last_receipt: ContextReceipt | None = None
105
+ self._last_hits = []
106
+ self._last_gated = 0
107
+ self._last_miss_flush = False
108
+ self.messages = []
109
+
110
+ self.repo: SessionRepository | None = None
111
+ self.vector_store = vector_store
112
+ self.embedder = embedder
113
+ self.indexer: MemoryIndexer | None = None
114
+ self.hyde: HyDE | None = None
115
+ self.extracts: ExtractScheduler | None = None
116
+ self.chunker = TokenChunker(
117
+ self.embedding_profile.chunk_tokens,
118
+ self.embedding_profile.chunk_overlap,
119
+ )
120
+ self.composer = DefaultQueryComposer(self.embedding_profile)
121
+
122
+ if state_path is not None:
123
+ self.repo = SessionRepository(state_path)
124
+ self.repo.ensure_session(self.session_id, default_skill="converse")
125
+ if self.vector_store is None:
126
+ vpath = vector_path or (Path(state_path).parent / "vectors")
127
+ self.vector_store = LanceVectorStore(
128
+ vpath, dimensions=self.embedding_profile.dimensions
129
+ )
130
+ if self.embedder is None:
131
+ self.embedder = LiteLLMEmbedder(self.embedding_profile)
132
+ gate = (
133
+ HeuristicMemoryGate()
134
+ if self.retrieval.memory_gate
135
+ else PassThroughGate()
136
+ )
137
+ self.indexer = MemoryIndexer(
138
+ self.repo, self.vector_store, self.embedder, gate=gate
139
+ )
140
+ self.indexer.flush_unindexed(self.session_id)
141
+ self.hyde = HyDE(self.profile)
142
+ self._load_transcript()
143
+
144
+ ctrl_i = None
145
+ ext_i = None
146
+ if compiled_profile:
147
+ ctrl_i = compiled_profile.instructions.get("controller")
148
+ ext_i = compiled_profile.instructions.get("extractor")
149
+ self.controller = Controller(self.profile, instruction=ctrl_i)
150
+ self.extractor = MemoryExtractor(self.profile, instruction=ext_i)
151
+ if self.repo is not None and self.indexer is not None:
152
+ session_metrics = getattr(self.trace, "metrics", None) if self.trace else None
153
+ self.extracts = ExtractScheduler(
154
+ repo=self.repo,
155
+ extractor=self.extractor,
156
+ indexer=self.indexer,
157
+ session_id=self.session_id,
158
+ mode=self.extract_mode,
159
+ model=self.model,
160
+ compiled_profile=self.compiled_profile,
161
+ trace=self.trace,
162
+ metrics=session_metrics,
163
+ )
164
+ self.prompt = self._build_prompt()
165
+ self._ensure_system()
166
+ self._turns = TurnRunner(self)
167
+
168
+ def _build_prompt(self) -> Prompt:
169
+ active = "converse"
170
+ goal = ""
171
+ skill_prompt = ""
172
+ tools = self.tools
173
+ if self.repo is not None:
174
+ active = self.repo.active_skill(self.session_id)
175
+ goal = self.repo.get_goal(self.session_id)
176
+ skill_prompt = self.skills.get(active).prompt
177
+ tools = self.skills.resolve_tools(active, self.tools)
178
+ return Prompt(
179
+ tools_text=tool_descriptions(tools),
180
+ multi_step=self.multi_step,
181
+ extra=self.system,
182
+ skill_prompt=skill_prompt,
183
+ goal=goal,
184
+ compiled=self.compiled_profile,
185
+ )
186
+
187
+ def _ensure_system(self):
188
+ self.prompt = self._build_prompt()
189
+ content = self.prompt.system()
190
+ if self.messages and self.messages[0].get("role") == "system":
191
+ self.messages[0] = system(content)
192
+ else:
193
+ self.messages.insert(0, system(content))
194
+
195
+ def _load_transcript(self):
196
+ if self.repo is None:
197
+ return
198
+ loaded = []
199
+ for m in self.repo.list_messages(self.session_id):
200
+ if m.role == "assistant":
201
+ loaded.append(assistant(m.content))
202
+ elif m.role == "system":
203
+ loaded.append(system(m.content))
204
+ else:
205
+ loaded.append(user(m.content))
206
+ self.messages = loaded
207
+
208
+ def clear(self):
209
+ self.messages = []
210
+ self.last_prompt = None
211
+ self.last_receipt = None
212
+ self._last_hits = []
213
+ self._last_gated = 0
214
+ self._last_miss_flush = False
215
+ if self.repo is not None:
216
+ self.repo.clear_session(self.session_id, default_skill="converse")
217
+ if self.vector_store is not None:
218
+ self.vector_store.delete_session(self.session_id)
219
+ if self.extracts is not None and self.extracts.metrics is not None:
220
+ self.extracts.metrics.observe_extract_queue(0)
221
+ self._ensure_system()
222
+ ctx = self.context
223
+ if ctx is not None:
224
+ on_clear = getattr(ctx, "on_clear", None)
225
+ if on_clear is not None:
226
+ on_clear()
227
+
228
+ @property
229
+ def goal(self) -> str:
230
+ return self.repo.get_goal(self.session_id) if self.repo else ""
231
+
232
+ @property
233
+ def stack(self):
234
+ return self.repo.stack(self.session_id) if self.repo else []
235
+
236
+ def _prompt_messages(self, messages):
237
+ view = list(messages)
238
+ if self.context is None:
239
+ return view
240
+ return self.context.prepare(view)
241
+
242
+ def _retrieve(self, user_text: str, retrieval_query: str):
243
+ """Vector retrieve; returns (hits, hyde_result_or_None)."""
244
+ if self.vector_store is None or self.embedder is None:
245
+ return [], None
246
+ result = retrieve_hits(
247
+ store=self.vector_store,
248
+ embedder=self.embedder,
249
+ composer=self.composer,
250
+ session_id=self.session_id,
251
+ user_text=user_text,
252
+ goal=self.repo.get_goal(self.session_id) if self.repo else "",
253
+ config=self.retrieval,
254
+ top_k=self.embedding_profile.top_k,
255
+ retrieval_query=retrieval_query or "",
256
+ hyde=self.hyde if self.retrieval.use_hyde else None,
257
+ )
258
+ return result.hits, result.hyde_result
259
+
260
+ def _finish(self, answer, steps, turn_metrics, stopped=None, *, control_decision=None):
261
+ self.messages.append(assistant(answer))
262
+ if self.repo is not None:
263
+ self.repo.append_message(
264
+ self.session_id, role="assistant", content=answer, kind="chat"
265
+ )
266
+ prompt_view = self.last_prompt or self._prompt_messages(self.messages)
267
+ summary = metrics_mod.summarize(
268
+ turn_metrics,
269
+ context_messages=len(self.messages),
270
+ prompt_messages=len(prompt_view),
271
+ )
272
+ stack_frames = [
273
+ {"skill": f.skill, "depth": f.depth, "note": f.note}
274
+ for f in self.stack
275
+ ]
276
+ if self.trace is not None:
277
+ self.trace.turn_end(
278
+ answer=answer,
279
+ metrics=summary,
280
+ messages=self.messages,
281
+ stopped=stopped,
282
+ stack=stack_frames,
283
+ goal=self.goal,
284
+ receipt=self.last_receipt,
285
+ control_decision=control_decision,
286
+ gated_chunks=self._last_gated,
287
+ extract_miss_flush=self._last_miss_flush,
288
+ )
289
+ out = {
290
+ "answer": answer,
291
+ "steps": steps,
292
+ "metrics": summary,
293
+ "receipt": self.last_receipt.as_dict() if self.last_receipt else None,
294
+ "goal": self.goal,
295
+ "stack": stack_frames,
296
+ }
297
+ if stopped:
298
+ out["stopped"] = stopped
299
+ return out
300
+
301
+ def ask(self, user_text: str):
302
+ if self.repo is None:
303
+ return ask_legacy(self, user_text)
304
+
305
+ turn_metrics = metrics_mod.empty_usage()
306
+ steps = []
307
+ self._last_miss_flush = False
308
+ if self.trace is not None:
309
+ self.trace.turn_start(user_text, self.messages, model=self.model)
310
+
311
+ stored = self.repo.append_message(
312
+ self.session_id, role="user", content=user_text, kind="chat"
313
+ )
314
+ self.messages.append(user(user_text))
315
+
316
+ demos = ""
317
+ if self.compiled_profile:
318
+ demos = str(
319
+ self.compiled_profile.demonstrations.get("controller") or ""
320
+ )
321
+ decision, ctrl_result = self.controller.decide(
322
+ user_text=user_text,
323
+ goal=self.repo.get_goal(self.session_id),
324
+ active_skill=self.repo.active_skill(self.session_id),
325
+ skill_descriptions=self.skills.descriptions(),
326
+ demos=demos,
327
+ )
328
+ ctrl_metrics = metrics_mod.from_llm_result(ctrl_result)
329
+ turn_metrics = metrics_mod.add_usage(turn_metrics, ctrl_metrics)
330
+ if self.trace is not None:
331
+ self.trace.llm(
332
+ model=self.model,
333
+ metrics=ctrl_metrics,
334
+ content=ctrl_result.get("content") or "",
335
+ name="control",
336
+ operation="control",
337
+ )
338
+ if decision.skill not in set(self.skills.names()):
339
+ decision = replace(
340
+ decision,
341
+ skill=self.repo.active_skill(self.session_id),
342
+ fallback=True,
343
+ )
344
+ active = apply_stack_decision(
345
+ self.repo, self.session_id, decision, self.skills
346
+ )
347
+ if self.trace is not None:
348
+ self.trace.control(decision)
349
+ self._ensure_system()
350
+
351
+ self._last_hits = []
352
+ rq = decision.retrieval_query or ""
353
+ try:
354
+ hits, hyde_result = self._retrieve(user_text, rq)
355
+ self._last_hits = hits
356
+ if hyde_result is not None:
357
+ hyde_metrics = metrics_mod.from_llm_result(hyde_result)
358
+ turn_metrics = metrics_mod.add_usage(turn_metrics, hyde_metrics)
359
+ if self.trace is not None:
360
+ self.trace.llm(
361
+ model=self.model,
362
+ metrics=hyde_metrics,
363
+ content=hyde_result.get("content") or "",
364
+ name="hyde",
365
+ operation="hyde",
366
+ )
367
+ except Exception:
368
+ self._last_hits = []
369
+
370
+ # Queue mode: drain pending extracts after retrieve.
371
+ # Miss (query + zero hits + pending) → drain + one re-retrieve.
372
+ # Otherwise drain lazily so backlog still clears without a miss.
373
+ if self.extracts is not None and self.extract_mode == "queue":
374
+ pending = self.extracts.pending_count()
375
+ if should_miss_flush(rq, self._last_hits, pending):
376
+ self.extracts.note_miss_flush()
377
+ self._last_miss_flush = True
378
+ drained = self.extracts.drain(reason="miss")
379
+ turn_metrics = metrics_mod.add_usage(turn_metrics, drained)
380
+ try:
381
+ hits, _ = self._retrieve(user_text, rq)
382
+ self._last_hits = hits
383
+ except Exception:
384
+ pass
385
+ elif pending > 0:
386
+ drained = self.extracts.drain(reason="lazy")
387
+ turn_metrics = metrics_mod.add_usage(turn_metrics, drained)
388
+
389
+ tools = self.skills.resolve_tools(active, self.tools)
390
+ answer, react_steps, turn_metrics, stopped = self._turns.run_react(
391
+ None, tools, turn_metrics
392
+ )
393
+ steps.extend(react_steps)
394
+
395
+ self._last_gated = 0
396
+ if self.indexer is not None:
397
+ self.indexer.add_text(
398
+ self.session_id,
399
+ user_text,
400
+ chunks=self.chunker.chunk(user_text),
401
+ source_message_id=stored.id,
402
+ kind="raw",
403
+ )
404
+ self._last_gated = int(self.indexer.last_gated or 0)
405
+ if self.extracts is not None:
406
+ turn_metrics = self.extracts.after_raw(
407
+ turn_metrics, anchor_message_id=stored.id
408
+ )
409
+
410
+ return self._finish(
411
+ answer, steps, turn_metrics, stopped=stopped, control_decision=decision
412
+ )
sallm/cli/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow `python -m sallm.cli`."""
2
+
3
+ from .chat import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
sallm/cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .chat import app
2
+
3
+ if __name__ == "__main__":
4
+ app()