litmark 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 (97) hide show
  1. litmark/__init__.py +7 -0
  2. litmark/agent/__init__.py +1 -0
  3. litmark/agent/base.py +151 -0
  4. litmark/agent/claude_sdk.py +345 -0
  5. litmark/agent/fake.py +157 -0
  6. litmark/agent/prompts.py +74 -0
  7. litmark/agent/runner.py +470 -0
  8. litmark/agent/tools.py +689 -0
  9. litmark/api/__init__.py +1 -0
  10. litmark/api/app.py +229 -0
  11. litmark/api/routes.py +649 -0
  12. litmark/bibliography.py +334 -0
  13. litmark/cli.py +211 -0
  14. litmark/db.py +216 -0
  15. litmark/documents.py +470 -0
  16. litmark/errors.py +71 -0
  17. litmark/events.py +135 -0
  18. litmark/extraction.py +726 -0
  19. litmark/jobs.py +183 -0
  20. litmark/references.py +448 -0
  21. litmark/services.py +170 -0
  22. litmark/static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  23. litmark/static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  24. litmark/static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  25. litmark/static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  26. litmark/static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  27. litmark/static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  28. litmark/static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  29. litmark/static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  30. litmark/static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  31. litmark/static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  32. litmark/static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  33. litmark/static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  34. litmark/static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  35. litmark/static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  36. litmark/static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  37. litmark/static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  38. litmark/static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  39. litmark/static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  40. litmark/static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  41. litmark/static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  42. litmark/static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  43. litmark/static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  44. litmark/static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  45. litmark/static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  46. litmark/static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  47. litmark/static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  48. litmark/static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  49. litmark/static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  50. litmark/static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  51. litmark/static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  52. litmark/static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  53. litmark/static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  54. litmark/static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  55. litmark/static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  56. litmark/static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  57. litmark/static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  58. litmark/static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  59. litmark/static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  60. litmark/static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  61. litmark/static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  62. litmark/static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  63. litmark/static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  64. litmark/static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  65. litmark/static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  66. litmark/static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  67. litmark/static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  68. litmark/static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  69. litmark/static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  70. litmark/static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  71. litmark/static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  72. litmark/static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  73. litmark/static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  74. litmark/static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  75. litmark/static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  76. litmark/static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  77. litmark/static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  78. litmark/static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  79. litmark/static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  80. litmark/static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  81. litmark/static/assets/editor-Bn9f9nF2.js +28 -0
  82. litmark/static/assets/editor-Bn9f9nF2.js.map +1 -0
  83. litmark/static/assets/index-BdJz60Ic.css +1 -0
  84. litmark/static/assets/index-C7zeCZgl.js +12 -0
  85. litmark/static/assets/index-C7zeCZgl.js.map +1 -0
  86. litmark/static/assets/katex-uLN8M5ZI.js +258 -0
  87. litmark/static/assets/katex-uLN8M5ZI.js.map +1 -0
  88. litmark/static/assets/pdf.worker.min-yatZIOMy.mjs +21 -0
  89. litmark/static/assets/pdfjs-BDm9ube5.js +13 -0
  90. litmark/static/assets/pdfjs-BDm9ube5.js.map +1 -0
  91. litmark/static/index.html +17 -0
  92. litmark/workspace.py +489 -0
  93. litmark-0.1.0.dist-info/METADATA +204 -0
  94. litmark-0.1.0.dist-info/RECORD +97 -0
  95. litmark-0.1.0.dist-info/WHEEL +4 -0
  96. litmark-0.1.0.dist-info/entry_points.txt +2 -0
  97. litmark-0.1.0.dist-info/licenses/LICENSE +21 -0
litmark/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Litmark — a local research reader, Markdown editor, and agent chat."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["__version__"]
@@ -0,0 +1 @@
1
+ """Agent adapters, project tools, and run scheduling."""
litmark/agent/base.py ADDED
@@ -0,0 +1,151 @@
1
+ """The provider-neutral agent interface.
2
+
3
+ One live backend is implemented (the Claude Agent SDK) plus a deterministic
4
+ fake used by tests. Everything above this module sees only normalized events.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import abc
10
+ from collections.abc import AsyncIterator
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+
14
+ # Normalized event names, shared with events.py and the browser.
15
+ TEXT_DELTA = "text_delta"
16
+ TOOL_STARTED = "tool_started"
17
+ TOOL_FINISHED = "tool_finished"
18
+ FILE_PROPOSED = "file_proposed"
19
+ FILE_CHANGED = "file_changed"
20
+ ERROR = "error"
21
+ DONE = "done"
22
+
23
+
24
+ @dataclass
25
+ class AgentEvent:
26
+ type: str
27
+ data: dict[str, Any] = field(default_factory=dict)
28
+
29
+ @classmethod
30
+ def text(cls, text: str) -> AgentEvent:
31
+ return cls(TEXT_DELTA, {"text": text})
32
+
33
+ @classmethod
34
+ def error(cls, message: str, *, kind: str = "backend_error") -> AgentEvent:
35
+ return cls(ERROR, {"message": message, "kind": kind})
36
+
37
+
38
+ @dataclass
39
+ class RunContext:
40
+ """The explicit context attached to a chat message.
41
+
42
+ This is *starting* context — it does not assert that the agent has read
43
+ every attached paper. The agent is expected to search and read pages.
44
+ """
45
+
46
+ note_id: str | None = None
47
+ note_revision: str | None = None
48
+ selection: str | None = None
49
+ document_ids: list[str] = field(default_factory=list)
50
+ reference_id: str | None = None
51
+ page_number: int | None = None
52
+
53
+ def to_json(self) -> dict[str, Any]:
54
+ return {
55
+ "note_id": self.note_id,
56
+ "note_revision": self.note_revision,
57
+ "selection": self.selection,
58
+ "document_ids": self.document_ids,
59
+ "reference_id": self.reference_id,
60
+ "page_number": self.page_number,
61
+ }
62
+
63
+ @classmethod
64
+ def from_json(cls, data: dict[str, Any] | None) -> RunContext:
65
+ data = data or {}
66
+ return cls(
67
+ note_id=data.get("note_id"),
68
+ note_revision=data.get("note_revision"),
69
+ selection=data.get("selection"),
70
+ document_ids=list(data.get("document_ids") or []),
71
+ reference_id=data.get("reference_id"),
72
+ page_number=data.get("page_number"),
73
+ )
74
+
75
+ def describe(self) -> str:
76
+ """A short plain-text description injected ahead of the user's message."""
77
+ lines: list[str] = []
78
+ if self.note_id:
79
+ lines.append(f"- Active note: {self.note_id} (revision {self.note_revision})")
80
+ if self.document_ids:
81
+ lines.append(f"- Attached documents: {', '.join(self.document_ids)}")
82
+ if self.reference_id:
83
+ lines.append(f"- Currently open source: {self.reference_id}")
84
+ if self.page_number:
85
+ lines.append(f"- Currently open page: {self.page_number}")
86
+ if self.selection:
87
+ snippet = self.selection if len(self.selection) < 2000 else self.selection[:2000] + "…"
88
+ lines.append(f"- Selected text:\n<selection>\n{snippet}\n</selection>")
89
+ if not lines:
90
+ return ""
91
+ header = (
92
+ "Context for this message (starting points, not a claim that every "
93
+ "attached paper has been read):"
94
+ )
95
+ return header + "\n" + "\n".join(lines)
96
+
97
+
98
+ @dataclass
99
+ class RunRequest:
100
+ run_id: str
101
+ conversation_id: str
102
+ prompt: str
103
+ context: RunContext
104
+ backend_session_id: str | None = None
105
+ kind: str = "chat" # "chat" or "summary"
106
+
107
+
108
+ @dataclass
109
+ class Availability:
110
+ """What `doctor` and the interface need to know about the backend."""
111
+
112
+ backend: str
113
+ installed: bool
114
+ authenticated: bool
115
+ message: str
116
+ detail: str | None = None
117
+
118
+ @property
119
+ def ready(self) -> bool:
120
+ return self.installed and self.authenticated
121
+
122
+ def to_json(self) -> dict[str, Any]:
123
+ return {
124
+ "backend": self.backend,
125
+ "installed": self.installed,
126
+ "authenticated": self.authenticated,
127
+ "ready": self.ready,
128
+ "message": self.message,
129
+ "detail": self.detail,
130
+ }
131
+
132
+
133
+ class AgentBackend(abc.ABC):
134
+ """A chat backend that can read the project and propose file edits."""
135
+
136
+ name: str = "unknown"
137
+
138
+ @abc.abstractmethod
139
+ def availability(self) -> Availability:
140
+ """Report installation and authentication without printing secrets."""
141
+
142
+ @abc.abstractmethod
143
+ def start_run(self, request: RunRequest) -> AsyncIterator[AgentEvent]:
144
+ """Run one turn, yielding normalized events until ``done``."""
145
+
146
+ @abc.abstractmethod
147
+ async def cancel_run(self, run_id: str) -> None:
148
+ """Cancel backend work. Hiding the output is not cancellation."""
149
+
150
+ def supports_resume(self) -> bool:
151
+ return False
@@ -0,0 +1,345 @@
1
+ """The live backend: the Claude Agent SDK for Python.
2
+
3
+ The project tools are exposed through an in-process SDK MCP server, so the
4
+ agent's tool loop drives them directly — no terminal screen scraping. Built-in
5
+ filesystem and shell tools are disabled: every edit must go through the
6
+ version-checked project tools.
7
+
8
+ Reference: https://code.claude.com/docs/en/agent-sdk/python
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import logging
15
+ import os
16
+ import shutil
17
+ from collections.abc import AsyncIterator
18
+ from typing import Any
19
+
20
+ from .base import (
21
+ DONE,
22
+ ERROR,
23
+ FILE_CHANGED,
24
+ TEXT_DELTA,
25
+ TOOL_FINISHED,
26
+ TOOL_STARTED,
27
+ AgentBackend,
28
+ AgentEvent,
29
+ Availability,
30
+ RunRequest,
31
+ )
32
+ from .prompts import SYSTEM_PROMPT
33
+ from .tools import TOOL_SCHEMAS, ProjectTools, as_text, dispatch
34
+
35
+ log = logging.getLogger(__name__)
36
+
37
+ MCP_SERVER_NAME = "research"
38
+ DEFAULT_MODEL = "claude-opus-5"
39
+
40
+ # Tool names as the SDK exposes them once the MCP server is registered.
41
+ ALLOWED_TOOLS = [f"mcp__{MCP_SERVER_NAME}__{schema['name']}" for schema in TOOL_SCHEMAS]
42
+
43
+ # Host tools that must never be available in research mode. Editing goes
44
+ # through the version-checked project tools; a shell would bypass every
45
+ # conflict check, and the web tools are unrelated to reading the corpus.
46
+ DISALLOWED_TOOLS = [
47
+ "Bash",
48
+ "BashOutput",
49
+ "KillShell",
50
+ "Write",
51
+ "Edit",
52
+ "MultiEdit",
53
+ "NotebookEdit",
54
+ "Read",
55
+ "Glob",
56
+ "Grep",
57
+ "WebFetch",
58
+ "WebSearch",
59
+ "Task",
60
+ "SlashCommand",
61
+ ]
62
+
63
+
64
+ def sdk_available() -> tuple[bool, str | None]:
65
+ try:
66
+ import claude_agent_sdk # noqa: F401
67
+ except ImportError as exc:
68
+ return False, str(exc)
69
+ return True, None
70
+
71
+
72
+ class ClaudeAgentBackend(AgentBackend):
73
+ name = "claude"
74
+
75
+ def __init__(
76
+ self,
77
+ tools: ProjectTools,
78
+ *,
79
+ cwd: str,
80
+ model: str | None = None,
81
+ effort: str | None = None,
82
+ ) -> None:
83
+ self._tools = tools
84
+ self._cwd = cwd
85
+ self._model = model or DEFAULT_MODEL
86
+ self._effort = effort
87
+ self._clients: dict[str, Any] = {}
88
+ self._cancelled: set[str] = set()
89
+
90
+ # --------------------------------------------------------- availability
91
+
92
+ def availability(self) -> Availability:
93
+ installed, error = sdk_available()
94
+ if not installed:
95
+ return Availability(
96
+ backend=self.name,
97
+ installed=False,
98
+ authenticated=False,
99
+ message=(
100
+ "The Claude Agent SDK is not installed. Install the agent extra: "
101
+ "pip install 'litmark[claude]'"
102
+ ),
103
+ detail=error,
104
+ )
105
+
106
+ runtime = shutil.which("claude")
107
+ if runtime is None:
108
+ return Availability(
109
+ backend=self.name,
110
+ installed=True,
111
+ authenticated=False,
112
+ message=(
113
+ "The Claude Agent SDK is installed but its `claude` runtime was "
114
+ "not found on PATH. Install Claude Code, or set cli_path."
115
+ ),
116
+ )
117
+
118
+ # Credentials are checked by presence only; never read or printed.
119
+ has_key = bool(os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_AUTH_TOKEN"))
120
+ has_profile = any(
121
+ os.path.isdir(os.path.expanduser(path))
122
+ for path in ("~/.config/anthropic", "~/.claude")
123
+ )
124
+ if not (has_key or has_profile):
125
+ return Availability(
126
+ backend=self.name,
127
+ installed=True,
128
+ authenticated=False,
129
+ message=(
130
+ "No provider credentials were found. Set ANTHROPIC_API_KEY, or "
131
+ "sign in with the Claude CLI. Provider access and billing are "
132
+ "separate from this application."
133
+ ),
134
+ )
135
+ return Availability(
136
+ backend=self.name,
137
+ installed=True,
138
+ authenticated=True,
139
+ message="Claude Agent SDK is installed and credentials are configured.",
140
+ detail=f"runtime: {runtime}",
141
+ )
142
+
143
+ def supports_resume(self) -> bool:
144
+ return True
145
+
146
+ # ------------------------------------------------------------- run loop
147
+
148
+ async def start_run(self, request: RunRequest) -> AsyncIterator[AgentEvent]:
149
+ installed, error = sdk_available()
150
+ if not installed:
151
+ yield AgentEvent.error(
152
+ "The Claude Agent SDK is not installed; install the 'claude' extra.",
153
+ kind="agent_unavailable",
154
+ )
155
+ yield AgentEvent(DONE, {"terminal_reason": "error"})
156
+ return
157
+
158
+ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
159
+
160
+ self._cancelled.discard(request.run_id)
161
+ options = ClaudeAgentOptions(
162
+ system_prompt=SYSTEM_PROMPT,
163
+ model=self._model,
164
+ cwd=self._cwd,
165
+ mcp_servers={MCP_SERVER_NAME: self._build_mcp_server(request)},
166
+ # `tools` is an explicit allowlist: the project tools are the only
167
+ # ones loaded. That keeps the built-in file, shell, and web tools
168
+ # out of research mode entirely — edits must be version-checked, and
169
+ # running arbitrary analysis code is a separately scoped capability.
170
+ tools=list(ALLOWED_TOOLS),
171
+ allowed_tools=list(ALLOWED_TOOLS),
172
+ # Belt and braces, in case a future runtime loads built-ins anyway.
173
+ disallowed_tools=list(DISALLOWED_TOOLS),
174
+ permission_mode="bypassPermissions",
175
+ include_partial_messages=True,
176
+ resume=request.backend_session_id,
177
+ max_turns=40,
178
+ **({"effort": self._effort} if self._effort else {}),
179
+ )
180
+
181
+ session_id: str | None = request.backend_session_id
182
+ client = ClaudeSDKClient(options=options)
183
+ self._clients[request.run_id] = client
184
+ try:
185
+ await client.connect()
186
+ await client.query(self._compose_prompt(request))
187
+ async for message in client.receive_response():
188
+ found = _session_id_of(message)
189
+ if found:
190
+ session_id = found
191
+ for event in self._translate(message):
192
+ yield event
193
+ for change in self._tools.drain_changes():
194
+ yield AgentEvent(FILE_CHANGED, change)
195
+ except asyncio.CancelledError:
196
+ await self._safe_interrupt(request.run_id)
197
+ yield AgentEvent(
198
+ DONE, {"terminal_reason": "cancelled", "backend_session_id": session_id}
199
+ )
200
+ raise
201
+ except Exception as exc: # noqa: BLE001 - reported verbatim, never simulated
202
+ log.exception("Claude Agent SDK run failed")
203
+ yield AgentEvent.error(f"{type(exc).__name__}: {exc}")
204
+ yield AgentEvent(DONE, {"terminal_reason": "error", "backend_session_id": session_id})
205
+ return
206
+ finally:
207
+ self._clients.pop(request.run_id, None)
208
+ try:
209
+ await client.disconnect()
210
+ except Exception: # noqa: BLE001 - disconnect is best-effort
211
+ pass
212
+
213
+ yield AgentEvent(
214
+ DONE, {"terminal_reason": "endTurn", "backend_session_id": session_id}
215
+ )
216
+
217
+ async def cancel_run(self, run_id: str) -> None:
218
+ """Interrupt the backend. Hiding output would not be cancellation."""
219
+ self._cancelled.add(run_id)
220
+ await self._safe_interrupt(run_id)
221
+
222
+ async def _safe_interrupt(self, run_id: str) -> None:
223
+ client = self._clients.get(run_id)
224
+ if client is None:
225
+ return
226
+ try:
227
+ await client.interrupt()
228
+ except Exception as exc: # noqa: BLE001 - surfaced as a run error upstream
229
+ log.warning("Interrupting run %s failed: %s", run_id, exc)
230
+
231
+ # -------------------------------------------------------------- helpers
232
+
233
+ def _compose_prompt(self, request: RunRequest) -> str:
234
+ described = request.context.describe()
235
+ return f"{described}\n\n{request.prompt}" if described else request.prompt
236
+
237
+ def _build_mcp_server(self, request: RunRequest) -> Any:
238
+ """Wrap the project tools as an in-process SDK MCP server."""
239
+ from claude_agent_sdk import create_sdk_mcp_server, tool
240
+
241
+ handlers = []
242
+ for schema in TOOL_SCHEMAS:
243
+ handlers.append(self._make_tool(tool, schema, request.run_id))
244
+ return create_sdk_mcp_server(
245
+ name=MCP_SERVER_NAME, version="0.1.0", tools=handlers
246
+ )
247
+
248
+ def _make_tool(self, tool_decorator: Any, schema: dict[str, Any], run_id: str) -> Any:
249
+ name = schema["name"]
250
+ project_tools = self._tools
251
+
252
+ @tool_decorator(name, schema["description"], schema["input_schema"])
253
+ async def handler(args: dict[str, Any]) -> dict[str, Any]:
254
+ arguments = dict(args or {})
255
+ if name in {"write_note", "patch_note", "write_summary"}:
256
+ arguments.setdefault("run_id", run_id)
257
+ loop = asyncio.get_running_loop()
258
+ result = await loop.run_in_executor(
259
+ None, lambda: dispatch(project_tools, name, arguments)
260
+ )
261
+ return {"content": [{"type": "text", "text": as_text(result)}]}
262
+
263
+ return handler
264
+
265
+ def _translate(self, message: Any) -> list[AgentEvent]:
266
+ """Map SDK messages onto the normalized event vocabulary."""
267
+ events: list[AgentEvent] = []
268
+ kind = type(message).__name__
269
+
270
+ if kind == "StreamEvent":
271
+ delta = getattr(message, "delta", None)
272
+ if isinstance(delta, str) and delta:
273
+ events.append(AgentEvent(TEXT_DELTA, {"text": delta, "partial": True}))
274
+ return events
275
+
276
+ if kind == "AssistantMessage":
277
+ for block in getattr(message, "content", []) or []:
278
+ block_kind = type(block).__name__
279
+ if block_kind == "TextBlock":
280
+ # Partial deltas already streamed the text; mark the final
281
+ # block so the client can reconcile rather than duplicate.
282
+ events.append(
283
+ AgentEvent(TEXT_DELTA, {"text": block.text, "final": True})
284
+ )
285
+ elif block_kind == "ToolUseBlock":
286
+ events.append(
287
+ AgentEvent(
288
+ TOOL_STARTED,
289
+ {
290
+ "tool": _short_tool_name(block.name),
291
+ "tool_use_id": block.id,
292
+ "input": _safe_input(block.input),
293
+ },
294
+ )
295
+ )
296
+ return events
297
+
298
+ if kind == "UserMessage":
299
+ for block in getattr(message, "content", []) or []:
300
+ if type(block).__name__ == "ToolResultBlock":
301
+ events.append(
302
+ AgentEvent(
303
+ TOOL_FINISHED,
304
+ {
305
+ "tool_use_id": block.tool_use_id,
306
+ "is_error": bool(getattr(block, "is_error", False)),
307
+ },
308
+ )
309
+ )
310
+ return events
311
+
312
+ if kind == "ResultMessage":
313
+ if getattr(message, "subtype", "") == "error":
314
+ events.append(AgentEvent.error(str(getattr(message, "result", "Run failed."))))
315
+ return events
316
+
317
+
318
+ def _short_tool_name(name: str) -> str:
319
+ """`mcp__research__read_pages` reads better as `read_pages` in the UI."""
320
+ return name.rsplit("__", 1)[-1] if name.startswith("mcp__") else name
321
+
322
+
323
+ def _safe_input(value: Any) -> dict[str, Any]:
324
+ if not isinstance(value, dict):
325
+ return {"value": str(value)[:500]}
326
+ trimmed: dict[str, Any] = {}
327
+ for key, item in value.items():
328
+ if isinstance(item, str) and len(item) > 500:
329
+ trimmed[key] = item[:500] + "…"
330
+ else:
331
+ trimmed[key] = item
332
+ return trimmed
333
+
334
+
335
+ def _session_id_of(message: Any) -> str | None:
336
+ """Find the backend session ID wherever this SDK version reports it."""
337
+ direct = getattr(message, "session_id", None)
338
+ if isinstance(direct, str) and direct:
339
+ return direct
340
+ data = getattr(message, "data", None)
341
+ if isinstance(data, dict):
342
+ candidate = data.get("session_id")
343
+ if isinstance(candidate, str) and candidate:
344
+ return candidate
345
+ return None
litmark/agent/fake.py ADDED
@@ -0,0 +1,157 @@
1
+ """A deterministic in-process backend for reproducible UI and event tests.
2
+
3
+ This exists so tests can exercise streaming, reconnection, cancellation, and
4
+ conflict handling without a provider. It is never substituted for a failed live
5
+ run: the server reports provider failures honestly and only uses this backend
6
+ when it is selected explicitly.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from collections.abc import AsyncIterator
13
+ from typing import Any
14
+
15
+ from .base import (
16
+ DONE,
17
+ FILE_CHANGED,
18
+ TOOL_FINISHED,
19
+ TOOL_STARTED,
20
+ AgentBackend,
21
+ AgentEvent,
22
+ Availability,
23
+ RunRequest,
24
+ )
25
+ from .tools import ProjectTools, dispatch
26
+
27
+ # Set by tests: a list of scripted steps per run, consumed in order.
28
+ # Each step is ("text", "...") or ("tool", name, arguments).
29
+ Step = tuple[Any, ...]
30
+
31
+ # A pseudo-tool the default summary script expands once a reference exists.
32
+ WRITE_SUMMARY_STEP = "__write_summary_with_citation"
33
+
34
+
35
+ class FakeBackend(AgentBackend):
36
+ name = "fake"
37
+
38
+ def __init__(self, tools: ProjectTools, script: list[Step] | None = None) -> None:
39
+ self._tools = tools
40
+ self.script: list[Step] = script or [("text", "This is a fake agent reply.")]
41
+ # When unset, summary runs get a scripted read → cite → write sequence
42
+ # derived from the document itself, so the summary pipeline is exercised
43
+ # end to end rather than reporting "finished without writing".
44
+ self.summary_script: list[Step] | None = None
45
+ self._cancelled: set[str] = set()
46
+ self.delay = 0.0
47
+
48
+ def _default_summary_steps(self, request: RunRequest) -> list[Step]:
49
+ document_id = request.context.document_ids[0] if request.context.document_ids else None
50
+ if document_id is None:
51
+ return [("text", "No document was attached, so no summary was written.")]
52
+
53
+ quote: str | None = None
54
+ title = document_id
55
+ try:
56
+ document = self._tools.documents.get(document_id)
57
+ title = document.display_title
58
+ page = self._tools.documents.page(document_id, 1)
59
+ first_body = next(
60
+ (line.text for line in page.lines if len(line.text) > 25),
61
+ None,
62
+ )
63
+ quote = first_body
64
+ except Exception: # noqa: BLE001 - the fake must not raise into the runner
65
+ quote = None
66
+
67
+ steps: list[Step] = [
68
+ ("text", f"Reading {title} to write its summary."),
69
+ ("tool", "read_pages", {"document_id": document_id, "pages": [1]}),
70
+ ]
71
+ if quote:
72
+ steps.append(
73
+ (
74
+ "tool",
75
+ "resolve_source",
76
+ {"document_id": document_id, "page_number": 1, "quote": quote},
77
+ )
78
+ )
79
+ steps.append(("tool", WRITE_SUMMARY_STEP, {"document_id": document_id}))
80
+ steps.append(("text", "Summary written."))
81
+ return steps
82
+
83
+ def availability(self) -> Availability:
84
+ return Availability(
85
+ backend="fake",
86
+ installed=True,
87
+ authenticated=True,
88
+ message="Fake backend for tests; no provider is contacted.",
89
+ )
90
+
91
+ def supports_resume(self) -> bool:
92
+ return True
93
+
94
+ async def start_run(self, request: RunRequest) -> AsyncIterator[AgentEvent]:
95
+ self._cancelled.discard(request.run_id)
96
+ script = self.script
97
+ if request.kind == "summary" and self.summary_script is None:
98
+ script = self._default_summary_steps(request)
99
+ elif request.kind == "summary":
100
+ script = self.summary_script or []
101
+
102
+ last_reference: str | None = None
103
+ for step in list(script):
104
+ if request.run_id in self._cancelled:
105
+ yield AgentEvent(DONE, {"terminal_reason": "cancelled"})
106
+ return
107
+ if self.delay:
108
+ await asyncio.sleep(self.delay)
109
+ if step[0] == "text":
110
+ for chunk in _chunks(step[1]):
111
+ yield AgentEvent.text(chunk)
112
+ elif step[0] == "tool":
113
+ name, arguments = step[1], dict(step[2])
114
+ if name == WRITE_SUMMARY_STEP:
115
+ name = "write_summary"
116
+ arguments = self._summary_arguments(arguments["document_id"], last_reference)
117
+ if name in {"write_note", "patch_note", "write_summary"}:
118
+ arguments.setdefault("run_id", request.run_id)
119
+ yield AgentEvent(TOOL_STARTED, {"tool": name, "input": arguments})
120
+ result = dispatch(self._tools, name, arguments)
121
+ if name == "resolve_source" and result.get("reference_id"):
122
+ last_reference = str(result["reference_id"])
123
+ yield AgentEvent(TOOL_FINISHED, {"tool": name, "result": result})
124
+ for change in self._tools.drain_changes():
125
+ yield AgentEvent(FILE_CHANGED, change)
126
+ yield AgentEvent(
127
+ DONE,
128
+ {"terminal_reason": "endTurn", "backend_session_id": f"fake-{request.conversation_id}"},
129
+ )
130
+
131
+ def _summary_arguments(
132
+ self, document_id: str, reference_id: str | None
133
+ ) -> dict[str, Any]:
134
+ """Build a version-checked summary write, citing what was resolved."""
135
+ existing = self._tools.read_summary(document_id)
136
+ citation = f" [p. 1](source:{reference_id})" if reference_id else ""
137
+ text = (
138
+ f"## Summary\n\n"
139
+ f"**Research question.** Placeholder summary produced by the test backend"
140
+ f"{citation}.\n\n"
141
+ f"**Data and method.** Not assessed by the fake backend.\n\n"
142
+ f"**Findings.** Not assessed by the fake backend.\n\n"
143
+ f"**Limitations.** This text comes from a deterministic test backend, "
144
+ f"not from a provider.\n"
145
+ )
146
+ return {
147
+ "document_id": document_id,
148
+ "text": text,
149
+ "expected_revision": existing["revision"],
150
+ }
151
+
152
+ async def cancel_run(self, run_id: str) -> None:
153
+ self._cancelled.add(run_id)
154
+
155
+
156
+ def _chunks(text: str, size: int = 24) -> list[str]:
157
+ return [text[i : i + size] for i in range(0, len(text), size)] or [""]