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.
- litmark/__init__.py +7 -0
- litmark/agent/__init__.py +1 -0
- litmark/agent/base.py +151 -0
- litmark/agent/claude_sdk.py +345 -0
- litmark/agent/fake.py +157 -0
- litmark/agent/prompts.py +74 -0
- litmark/agent/runner.py +470 -0
- litmark/agent/tools.py +689 -0
- litmark/api/__init__.py +1 -0
- litmark/api/app.py +229 -0
- litmark/api/routes.py +649 -0
- litmark/bibliography.py +334 -0
- litmark/cli.py +211 -0
- litmark/db.py +216 -0
- litmark/documents.py +470 -0
- litmark/errors.py +71 -0
- litmark/events.py +135 -0
- litmark/extraction.py +726 -0
- litmark/jobs.py +183 -0
- litmark/references.py +448 -0
- litmark/services.py +170 -0
- litmark/static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- litmark/static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- litmark/static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- litmark/static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- litmark/static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- litmark/static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- litmark/static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- litmark/static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- litmark/static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- litmark/static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- litmark/static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- litmark/static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- litmark/static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- litmark/static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- litmark/static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- litmark/static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- litmark/static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- litmark/static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- litmark/static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- litmark/static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- litmark/static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- litmark/static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- litmark/static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- litmark/static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- litmark/static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- litmark/static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- litmark/static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- litmark/static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- litmark/static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- litmark/static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- litmark/static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- litmark/static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- litmark/static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- litmark/static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- litmark/static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- litmark/static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- litmark/static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- litmark/static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- litmark/static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- litmark/static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- litmark/static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- litmark/static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- litmark/static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- litmark/static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- litmark/static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- litmark/static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- litmark/static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- litmark/static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- litmark/static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- litmark/static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- litmark/static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- litmark/static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- litmark/static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- litmark/static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- litmark/static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- litmark/static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- litmark/static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- litmark/static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- litmark/static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- litmark/static/assets/editor-Bn9f9nF2.js +28 -0
- litmark/static/assets/editor-Bn9f9nF2.js.map +1 -0
- litmark/static/assets/index-BdJz60Ic.css +1 -0
- litmark/static/assets/index-C7zeCZgl.js +12 -0
- litmark/static/assets/index-C7zeCZgl.js.map +1 -0
- litmark/static/assets/katex-uLN8M5ZI.js +258 -0
- litmark/static/assets/katex-uLN8M5ZI.js.map +1 -0
- litmark/static/assets/pdf.worker.min-yatZIOMy.mjs +21 -0
- litmark/static/assets/pdfjs-BDm9ube5.js +13 -0
- litmark/static/assets/pdfjs-BDm9ube5.js.map +1 -0
- litmark/static/index.html +17 -0
- litmark/workspace.py +489 -0
- litmark-0.1.0.dist-info/METADATA +204 -0
- litmark-0.1.0.dist-info/RECORD +97 -0
- litmark-0.1.0.dist-info/WHEEL +4 -0
- litmark-0.1.0.dist-info/entry_points.txt +2 -0
- litmark-0.1.0.dist-info/licenses/LICENSE +21 -0
litmark/__init__.py
ADDED
|
@@ -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 [""]
|