simple-agents-framework 0.1.0__tar.gz

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.
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: simple_agents_framework
3
+ Version: 0.1.0
4
+ Summary: A markdown file in, a callable agent out. One Python file over the Claude Agent SDK.
5
+ Author: Agentsable
6
+ Project-URL: Homepage, https://github.com/Agentsable/simple_agents_framework
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: claude-agent-sdk>=0.1.52
10
+
11
+ # simple_agents_framework
12
+
13
+ A markdown file in, a callable agent out. One Python file over the
14
+ [Claude Agent SDK](https://github.com/anthropics/claude-agent-sdk-python).
15
+
16
+ ```python
17
+ import simple_agents_framework as saf
18
+
19
+ agent = saf.create_agent_from_markdown("doc_reader.md", API_KEY)
20
+ agent.ask("which markdown files are here?") # -> str
21
+ agent.ask_html("...") # same, streamed as HTML in Jupyter
22
+ ```
23
+
24
+ ## Agent files
25
+
26
+ Frontmatter configures, body is the system prompt. Every key is optional:
27
+
28
+ | key | effect |
29
+ |---|---|
30
+ | `name` | agent name (defaults to the filename) |
31
+ | `model` | e.g. `claude-sonnet-5` |
32
+ | `tools` | comma-separated allowlist, e.g. `Read, Grep, Glob` |
33
+ | `permission_mode` | defaults to `bypassPermissions` — headless agents can't answer prompts |
34
+
35
+ Anything else passes through as a `ClaudeAgentOptions` kwarg:
36
+ `create_agent_from_markdown(path, key, cwd="/repo", max_turns=5)`.
37
+
38
+ ## API
39
+
40
+ - `ask(prompt) -> str` — blocks, works inside Jupyter too.
41
+ - `ask_html(prompt) -> str` — same run, streamed into the cell: blue sent,
42
+ green agent, slate thinking, amber tool call, cyan tool result, red error.
43
+ The agent's text fills in token by token; tool calls and results appear whole.
44
+ - `ask_async(prompt, on_event=None)` — `on_event(kind, title, body, replace)`
45
+ per update. `replace=True` means "same block, more text": redraw the last
46
+ thing you drew instead of appending. Build your own renderer on this.
47
+
48
+ Follow-up asks resume the previous session, so context carries over.
49
+
50
+ ## Files
51
+
52
+ - `simple_agents_framework.py` — the whole thing. `python simple_agents_framework.py` runs its self-check.
53
+ - `doc_reader.md` — example agent: reads the markdown in a project and answers questions about it.
54
+ - `demo.ipynb` — end-to-end walkthrough with streamed output.
55
+
56
+ ## Gotcha
57
+
58
+ The SDK spawns whatever `claude` is first on `PATH`. Some tools install a
59
+ wrapper there that never returns headlessly — if `ask()` hangs, pass
60
+ `cli_path=Path.home() / ".local/bin/claude"`.
@@ -0,0 +1,50 @@
1
+ # simple_agents_framework
2
+
3
+ A markdown file in, a callable agent out. One Python file over the
4
+ [Claude Agent SDK](https://github.com/anthropics/claude-agent-sdk-python).
5
+
6
+ ```python
7
+ import simple_agents_framework as saf
8
+
9
+ agent = saf.create_agent_from_markdown("doc_reader.md", API_KEY)
10
+ agent.ask("which markdown files are here?") # -> str
11
+ agent.ask_html("...") # same, streamed as HTML in Jupyter
12
+ ```
13
+
14
+ ## Agent files
15
+
16
+ Frontmatter configures, body is the system prompt. Every key is optional:
17
+
18
+ | key | effect |
19
+ |---|---|
20
+ | `name` | agent name (defaults to the filename) |
21
+ | `model` | e.g. `claude-sonnet-5` |
22
+ | `tools` | comma-separated allowlist, e.g. `Read, Grep, Glob` |
23
+ | `permission_mode` | defaults to `bypassPermissions` — headless agents can't answer prompts |
24
+
25
+ Anything else passes through as a `ClaudeAgentOptions` kwarg:
26
+ `create_agent_from_markdown(path, key, cwd="/repo", max_turns=5)`.
27
+
28
+ ## API
29
+
30
+ - `ask(prompt) -> str` — blocks, works inside Jupyter too.
31
+ - `ask_html(prompt) -> str` — same run, streamed into the cell: blue sent,
32
+ green agent, slate thinking, amber tool call, cyan tool result, red error.
33
+ The agent's text fills in token by token; tool calls and results appear whole.
34
+ - `ask_async(prompt, on_event=None)` — `on_event(kind, title, body, replace)`
35
+ per update. `replace=True` means "same block, more text": redraw the last
36
+ thing you drew instead of appending. Build your own renderer on this.
37
+
38
+ Follow-up asks resume the previous session, so context carries over.
39
+
40
+ ## Files
41
+
42
+ - `simple_agents_framework.py` — the whole thing. `python simple_agents_framework.py` runs its self-check.
43
+ - `doc_reader.md` — example agent: reads the markdown in a project and answers questions about it.
44
+ - `demo.ipynb` — end-to-end walkthrough with streamed output.
45
+
46
+ ## Gotcha
47
+
48
+ The SDK spawns whatever `claude` is first on `PATH`. Some tools install a
49
+ wrapper there that never returns headlessly — if `ask()` hangs, pass
50
+ `cli_path=Path.home() / ".local/bin/claude"`.
@@ -0,0 +1,18 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "simple_agents_framework"
7
+ version = "0.1.0"
8
+ description = "A markdown file in, a callable agent out. One Python file over the Claude Agent SDK."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ authors = [{ name = "Agentsable" }]
12
+ dependencies = ["claude-agent-sdk>=0.1.52"]
13
+
14
+ [project.urls]
15
+ Homepage = "https://github.com/Agentsable/simple_agents_framework"
16
+
17
+ [tool.setuptools]
18
+ py-modules = ["simple_agents_framework"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: simple_agents_framework
3
+ Version: 0.1.0
4
+ Summary: A markdown file in, a callable agent out. One Python file over the Claude Agent SDK.
5
+ Author: Agentsable
6
+ Project-URL: Homepage, https://github.com/Agentsable/simple_agents_framework
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: claude-agent-sdk>=0.1.52
10
+
11
+ # simple_agents_framework
12
+
13
+ A markdown file in, a callable agent out. One Python file over the
14
+ [Claude Agent SDK](https://github.com/anthropics/claude-agent-sdk-python).
15
+
16
+ ```python
17
+ import simple_agents_framework as saf
18
+
19
+ agent = saf.create_agent_from_markdown("doc_reader.md", API_KEY)
20
+ agent.ask("which markdown files are here?") # -> str
21
+ agent.ask_html("...") # same, streamed as HTML in Jupyter
22
+ ```
23
+
24
+ ## Agent files
25
+
26
+ Frontmatter configures, body is the system prompt. Every key is optional:
27
+
28
+ | key | effect |
29
+ |---|---|
30
+ | `name` | agent name (defaults to the filename) |
31
+ | `model` | e.g. `claude-sonnet-5` |
32
+ | `tools` | comma-separated allowlist, e.g. `Read, Grep, Glob` |
33
+ | `permission_mode` | defaults to `bypassPermissions` — headless agents can't answer prompts |
34
+
35
+ Anything else passes through as a `ClaudeAgentOptions` kwarg:
36
+ `create_agent_from_markdown(path, key, cwd="/repo", max_turns=5)`.
37
+
38
+ ## API
39
+
40
+ - `ask(prompt) -> str` — blocks, works inside Jupyter too.
41
+ - `ask_html(prompt) -> str` — same run, streamed into the cell: blue sent,
42
+ green agent, slate thinking, amber tool call, cyan tool result, red error.
43
+ The agent's text fills in token by token; tool calls and results appear whole.
44
+ - `ask_async(prompt, on_event=None)` — `on_event(kind, title, body, replace)`
45
+ per update. `replace=True` means "same block, more text": redraw the last
46
+ thing you drew instead of appending. Build your own renderer on this.
47
+
48
+ Follow-up asks resume the previous session, so context carries over.
49
+
50
+ ## Files
51
+
52
+ - `simple_agents_framework.py` — the whole thing. `python simple_agents_framework.py` runs its self-check.
53
+ - `doc_reader.md` — example agent: reads the markdown in a project and answers questions about it.
54
+ - `demo.ipynb` — end-to-end walkthrough with streamed output.
55
+
56
+ ## Gotcha
57
+
58
+ The SDK spawns whatever `claude` is first on `PATH`. Some tools install a
59
+ wrapper there that never returns headlessly — if `ask()` hangs, pass
60
+ `cli_path=Path.home() / ".local/bin/claude"`.
@@ -0,0 +1,8 @@
1
+ README.md
2
+ pyproject.toml
3
+ simple_agents_framework.py
4
+ simple_agents_framework.egg-info/PKG-INFO
5
+ simple_agents_framework.egg-info/SOURCES.txt
6
+ simple_agents_framework.egg-info/dependency_links.txt
7
+ simple_agents_framework.egg-info/requires.txt
8
+ simple_agents_framework.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ claude-agent-sdk>=0.1.52
@@ -0,0 +1 @@
1
+ simple_agents_framework
@@ -0,0 +1,261 @@
1
+ """Markdown-defined agents on top of the Claude Agent SDK.
2
+
3
+ import simple_agents_framework as saf
4
+ agent = saf.create_agent_from_markdown("researcher.md", anthropic_api_key)
5
+ print(agent.ask("what changed in the repo today?"))
6
+
7
+ In a Jupyter notebook, agent.ask_html(...) streams the same run as color-coded
8
+ HTML: the prompt, the agent's text as it is typed, each tool call, and each
9
+ tool result.
10
+
11
+ Markdown file = optional YAML-ish frontmatter + body (the system prompt):
12
+
13
+ ---
14
+ name: researcher
15
+ description: digs through code
16
+ model: claude-opus-5
17
+ tools: Read, Grep, Glob
18
+ ---
19
+ You are a careful code researcher. Answer with file:line references.
20
+ """
21
+
22
+ import asyncio
23
+ import html
24
+ import json
25
+ import os
26
+ import re
27
+ import threading
28
+ import time
29
+ from pathlib import Path
30
+
31
+ from claude_agent_sdk import (
32
+ AssistantMessage,
33
+ ClaudeAgentOptions,
34
+ ResultMessage,
35
+ TextBlock,
36
+ StreamEvent,
37
+ ThinkingBlock,
38
+ ToolResultBlock,
39
+ ToolUseBlock,
40
+ query,
41
+ )
42
+
43
+ __all__ = ["Agent", "create_agent_from_markdown"]
44
+
45
+ # label -> accent color, one per kind of thing that shows up in a run.
46
+ COLORS = {
47
+ "sent": "#2563eb", # blue - what you sent
48
+ "received": "#16a34a", # green - what the agent said
49
+ "thinking": "#64748b", # slate - reasoning
50
+ "tool use": "#d97706", # amber - tool call
51
+ "tool result": "#0891b2", # cyan - tool output
52
+ "error": "#dc2626", # red
53
+ }
54
+
55
+
56
+ _loop = None
57
+
58
+
59
+ def _run(coro):
60
+ """Run a coroutine on one shared background loop, from sync code.
61
+
62
+ ponytail: a background loop rather than asyncio.run per call — that closes
63
+ the child-process watcher every time (noisy warnings) and blows up inside
64
+ Jupyter, which already owns the main loop.
65
+ """
66
+ global _loop
67
+ if _loop is None:
68
+ _loop = asyncio.new_event_loop()
69
+ threading.Thread(target=_loop.run_forever, daemon=True).start()
70
+ return asyncio.run_coroutine_threadsafe(coro, _loop).result()
71
+
72
+
73
+ def _card(kind, title, body):
74
+ color = COLORS[kind] # kind is always one of the six above
75
+ return (
76
+ f'<div style="border-left:3px solid {color};background:{color}14;'
77
+ 'padding:6px 10px;margin:4px 0;border-radius:0 4px 4px 0;'
78
+ 'font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px">'
79
+ f'<div style="color:{color};font-weight:600;letter-spacing:.04em;'
80
+ f'text-transform:uppercase;font-size:10.5px">{html.escape(title)}</div>'
81
+ f'<div style="white-space:pre-wrap;color:inherit;opacity:.9">{html.escape(body)}</div>'
82
+ "</div>"
83
+ )
84
+
85
+
86
+ def _clip(text, limit=1200):
87
+ if isinstance(text, list): # tool results arrive as content blocks
88
+ text = "\n".join(b.get("text", str(b)) if isinstance(b, dict) else str(b) for b in text)
89
+ text = str(text).strip()
90
+ return text if len(text) <= limit else text[:limit] + f"\n… (+{len(text) - limit} chars)"
91
+
92
+
93
+ def _parse_markdown(text):
94
+ """-> (metadata dict, system prompt). Frontmatter is flat `key: value` lines."""
95
+ meta, body = {}, text
96
+ if text.lstrip().startswith("---"):
97
+ _, front, body = text.lstrip().split("---", 2)
98
+ meta = {k: v.strip() for k, v in re.findall(r"^(\w+)\s*:\s*(.*)$", front, re.M)}
99
+ return meta, body.strip()
100
+
101
+
102
+ class Agent:
103
+ def __init__(self, name, options):
104
+ self.name = name
105
+ self.options = options
106
+
107
+ def ask(self, prompt):
108
+ """Send a prompt, return the agent's final text. Blocks until done."""
109
+ return _run(self.ask_async(prompt))
110
+
111
+ def ask_html(self, prompt):
112
+ """Same, but stream the run into a Jupyter cell as color-coded HTML."""
113
+ from IPython.display import HTML, display
114
+
115
+ cards = [_card("sent", "sent", prompt)]
116
+ handle = display(HTML("".join(cards)), display_id=True)
117
+ state = {"open": False, "drawn": 0.0}
118
+
119
+ def flush(force=False):
120
+ # ponytail: ~20fps. Every token would be its own display update,
121
+ # each re-joining the whole card list. force=True on the last draw.
122
+ if force or time.monotonic() - state["drawn"] > 0.05:
123
+ state["drawn"] = time.monotonic()
124
+ handle.update(HTML("".join(cards)))
125
+
126
+ def on_event(kind, title, body, replace):
127
+ card = _card(kind, title, body)
128
+ if replace and state["open"]:
129
+ cards[-1] = card
130
+ else:
131
+ cards.append(card)
132
+ state["open"] = replace
133
+ flush(force=not replace)
134
+
135
+ try:
136
+ return _run(self.ask_async(prompt, on_event))
137
+ finally:
138
+ flush(force=True) # the throttle may have skipped the last tokens
139
+
140
+ async def ask_async(self, prompt, on_event=None):
141
+ """Run the prompt. on_event(kind, title, body, replace) per update;
142
+ replace=True means "same block, more text" — redraw, don't append."""
143
+ emit = on_event or (lambda *a: None)
144
+ partial = self.options.include_partial_messages
145
+ text, buf = [], ""
146
+ async for message in query(prompt=prompt, options=self.options):
147
+ if isinstance(message, StreamEvent):
148
+ event = message.event
149
+ if event.get("type") == "content_block_delta":
150
+ delta = event.get("delta", {})
151
+ chunk = delta.get("text") or delta.get("thinking") or ""
152
+ if chunk: # tool-input deltas have neither; they land whole below
153
+ buf += chunk
154
+ thinking = delta.get("type") == "thinking_delta"
155
+ emit(*(("thinking", "thinking") if thinking
156
+ else ("received", self.name)), buf, True)
157
+ elif event.get("type") == "content_block_stop":
158
+ buf = ""
159
+ continue
160
+ for block in getattr(message, "content", []) or []:
161
+ if isinstance(block, TextBlock):
162
+ if isinstance(message, AssistantMessage):
163
+ text.append(block.text)
164
+ if not partial: # else it already streamed in, token by token
165
+ emit("received", self.name, block.text, False)
166
+ elif isinstance(block, ThinkingBlock):
167
+ if not partial:
168
+ emit("thinking", "thinking", _clip(block.thinking), False)
169
+ elif isinstance(block, ToolUseBlock):
170
+ emit("tool use", block.name, _clip(json.dumps(block.input, indent=2)), False)
171
+ elif isinstance(block, ToolResultBlock):
172
+ kind = "error" if block.is_error else "tool result"
173
+ emit(kind, kind, _clip(block.content), False)
174
+ if isinstance(message, ResultMessage):
175
+ # ponytail: resuming the session is how follow-up asks keep context.
176
+ # Drop these two lines if you want every ask() stateless.
177
+ if message.session_id:
178
+ self.options.resume = message.session_id
179
+ if message.result:
180
+ return message.result
181
+ return "\n".join(text)
182
+
183
+ def __repr__(self):
184
+ return f"<Agent {self.name!r}>"
185
+
186
+
187
+ def create_agent_from_markdown(markdown_file_path, anthropic_api_key=None, **overrides):
188
+ """Build an Agent from a markdown file. Extra kwargs go to ClaudeAgentOptions."""
189
+ path = Path(markdown_file_path)
190
+ meta, system_prompt = _parse_markdown(path.read_text(encoding="utf-8"))
191
+
192
+ env = dict(os.environ)
193
+ if anthropic_api_key:
194
+ env["ANTHROPIC_API_KEY"] = anthropic_api_key
195
+ elif "ANTHROPIC_API_KEY" not in env:
196
+ raise ValueError("no anthropic_api_key given and ANTHROPIC_API_KEY is unset")
197
+
198
+ opts = dict(
199
+ system_prompt=system_prompt,
200
+ env=env,
201
+ # ponytail: headless agents can't answer permission prompts. Pass
202
+ # permission_mode="acceptEdits" (or "plan") if bypass is too much.
203
+ permission_mode=meta.get("permission_mode", "bypassPermissions"),
204
+ # ponytail: always on rather than a flag — the extra pipe traffic is
205
+ # cheap and it's what makes ask_html fill in word by word.
206
+ include_partial_messages=True,
207
+ )
208
+ if "model" in meta:
209
+ opts["model"] = meta["model"]
210
+ if "tools" in meta:
211
+ opts["allowed_tools"] = [t.strip() for t in meta["tools"].split(",") if t.strip()]
212
+ opts.update(overrides)
213
+
214
+ return Agent(meta.get("name", path.stem), ClaudeAgentOptions(**opts))
215
+
216
+
217
+ if __name__ == "__main__":
218
+ meta, body = _parse_markdown(
219
+ "---\nname: bob\ntools: Read, Grep\n---\nYou are bob.\n\n---\nnot frontmatter\n"
220
+ )
221
+ assert meta == {"name": "bob", "tools": "Read, Grep"}, meta
222
+ assert body == "You are bob.\n\n---\nnot frontmatter", repr(body)
223
+
224
+ meta, body = _parse_markdown("Just a prompt.")
225
+ assert meta == {} and body == "Just a prompt."
226
+
227
+ tmp = Path("_saf_selfcheck.md")
228
+ tmp.write_text("---\nname: bob\nmodel: claude-opus-5\ntools: Read\n---\nYou are bob.")
229
+ try:
230
+ a = create_agent_from_markdown(tmp, "sk-test")
231
+ assert a.name == "bob" and a.options.model == "claude-opus-5"
232
+ assert a.options.allowed_tools == ["Read"]
233
+ assert a.options.env["ANTHROPIC_API_KEY"] == "sk-test"
234
+ assert a.options.include_partial_messages is True
235
+
236
+ # token streaming: deltas redraw one card, the finished block must not
237
+ # append a second copy of the same text.
238
+ import claude_agent_sdk as _sdk
239
+
240
+ async def fake_query(prompt, options):
241
+ for chunk in ("Hel", "lo ", "there"):
242
+ yield _sdk.StreamEvent(uuid="u", session_id="s", event={
243
+ "type": "content_block_delta",
244
+ "delta": {"type": "text_delta", "text": chunk}})
245
+ yield _sdk.StreamEvent(uuid="u", session_id="s",
246
+ event={"type": "content_block_stop"})
247
+ yield AssistantMessage(content=[TextBlock("Hello there")], model="m")
248
+ yield ResultMessage(subtype="success", duration_ms=1, duration_api_ms=1,
249
+ is_error=False, num_turns=1, session_id="sess-1",
250
+ result="Hello there")
251
+
252
+ global query
253
+ query, seen = fake_query, []
254
+ out = _run(a.ask_async("hi", lambda *e: seen.append(e)))
255
+ assert out == "Hello there", out
256
+ assert [e[2] for e in seen] == ["Hel", "Hello ", "Hello there"], seen
257
+ assert all(e[3] for e in seen), "deltas must be replace=True"
258
+ assert a.options.resume == "sess-1"
259
+ finally:
260
+ tmp.unlink()
261
+ print("ok")