agentropic 0.4.0__tar.gz → 0.4.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentropic
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: A minimal, hackable agentic framework: LLM calls via litellm, @tool-decorated functions, and MCP server support.
5
5
  Author-email: Vishal Tiwari <vishaltiwari.up2019@gmail.com>
6
6
  License: MIT
@@ -61,4 +61,4 @@ async def mode_b_pause_resume():
61
61
 
62
62
  if __name__ == "__main__":
63
63
  asyncio.run(mode_a_inline())
64
- asyncio.run(mode_b_pause_resume())
64
+ # asyncio.run(mode_b_pause_resume())
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agentropic"
7
- version = "0.4.0"
7
+ version = "0.4.2"
8
8
  description = "A minimal, hackable agentic framework: LLM calls via litellm, @tool-decorated functions, and MCP server support."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -38,12 +38,14 @@ from contextlib import AsyncExitStack
38
38
  from dataclasses import asdict, dataclass, field
39
39
 
40
40
  from .hitl import (
41
+ APPROVAL_SYSTEM_NOTE,
41
42
  CANCELLED_MESSAGE,
42
- DEFAULT_DENIAL_MESSAGE,
43
43
  ApprovalDecision,
44
44
  ApprovalHandler,
45
45
  ApprovalRequest,
46
46
  RunState,
47
+ denial_message,
48
+ denial_reminder,
47
49
  evaluate_policy,
48
50
  )
49
51
  from .llm import LLMClient, LLMResponse
@@ -197,6 +199,7 @@ class _RunCtx:
197
199
  decisions: dict[str, ApprovalDecision] = field(default_factory=dict)
198
200
  sticky: dict[str, bool] = field(default_factory=dict)
199
201
  pending: list[ApprovalRequest] = field(default_factory=list)
202
+ denied_notes: list[str] = field(default_factory=list) # "name(args)" of calls a human denied
200
203
  run_id: str = field(default_factory=lambda: uuid.uuid4().hex)
201
204
 
202
205
 
@@ -303,8 +306,14 @@ class Agent:
303
306
 
304
307
  # -- message building --------------------------------------------------
305
308
 
309
+ def _uses_approvals(self) -> bool:
310
+ return any(tl.requires_approval for tl in self.registry.list())
311
+
306
312
  def _build_messages(self, user_input: str) -> list[dict]:
307
- messages = [{"role": "system", "content": self.instructions}]
313
+ system = self.instructions
314
+ if self._uses_approvals():
315
+ system = f"{system}\n\n{APPROVAL_SYSTEM_NOTE}"
316
+ messages = [{"role": "system", "content": system}]
308
317
  messages.extend(self.memory.get())
309
318
  messages.append({"role": "user", "content": user_input})
310
319
  return messages
@@ -421,10 +430,8 @@ class Agent:
421
430
  edited=decision.override_args is not None, auto=auto,
422
431
  )
423
432
  if not decision.approved:
424
- content = (
425
- f"Tool call denied by user: {decision.message}"
426
- if decision.message else DEFAULT_DENIAL_MESSAGE
427
- )
433
+ content = denial_message(name, decision.message)
434
+ ctx.denied_notes.append(f"{name}({json.dumps(args, default=str)})")
428
435
  record = ToolCallRecord(
429
436
  id=call_id, name=name, arguments=args, result=content,
430
437
  error=True, iteration=iteration, status="denied",
@@ -443,6 +450,10 @@ class Agent:
443
450
  tool_call=ToolCallRecord(id=call_id, name=name, arguments=args, iteration=iteration),
444
451
  )
445
452
  tool_msg, record = await self._execute_tool(call_id, name, args, iteration)
453
+ if decision is not None and decision.approved and not record.error:
454
+ # Tell the model approval was granted, so it doesn't report
455
+ # the action as "still needing approval".
456
+ tool_msg["content"] = f"[Approved by the user and executed] {tool_msg['content']}"
446
457
  self._record_tool_result(ctx, tool_msg, record)
447
458
  if stream:
448
459
  yield StreamEvent(type="tool_result", iteration=iteration, tool_call=record)
@@ -560,9 +571,19 @@ class Agent:
560
571
  for iteration in range(first_iteration, self.max_iterations + 1):
561
572
  self.tracer.iteration_start(iteration, self.max_iterations)
562
573
  llm_started = time.monotonic()
574
+ # If a human denied any call in this run, put an explicit
575
+ # reminder right next to where the model writes its answer.
576
+ # Small models otherwise ignore the DENIED tool result and
577
+ # summarise the original request as if everything ran.
578
+ # Transient: sent with this request only, never stored.
579
+ request_messages = ctx.messages
580
+ if ctx.denied_notes:
581
+ request_messages = ctx.messages + [
582
+ {"role": "user", "content": denial_reminder(ctx.denied_notes)}
583
+ ]
563
584
  if stream:
564
585
  response = None
565
- async for item in self.llm.astream_response(ctx.messages, tools=tools_schema):
586
+ async for item in self.llm.astream_response(request_messages, tools=tools_schema):
566
587
  if isinstance(item, str):
567
588
  self.tracer.stream_text(item)
568
589
  yield StreamEvent(type="text", iteration=iteration, text=item)
@@ -570,7 +591,7 @@ class Agent:
570
591
  response = item
571
592
  assert response is not None
572
593
  else:
573
- response = await self.llm.acomplete(ctx.messages, tools=tools_schema)
594
+ response = await self.llm.acomplete(request_messages, tools=tools_schema)
574
595
  ctx.raw_responses.append(response)
575
596
  self.tracer.llm_response(
576
597
  response.content or "",
@@ -613,6 +634,7 @@ class Agent:
613
634
  self.tracer.final_answer(
614
635
  result.output, result.iterations, len(result.tool_calls),
615
636
  result.duration_seconds, result.usage, streamed=stream,
637
+ denied=[tc.name for tc in result.tool_calls if tc.status == "denied"],
616
638
  )
617
639
  yield StreamEvent(type="done", iteration=iteration, result=result)
618
640
  return
@@ -39,8 +39,46 @@ STATE_VERSION = 1
39
39
  # (sync or async). Used on @tool(requires_approval=...).
40
40
  ApprovalPolicy = t.Union[bool, t.Callable[[dict], t.Union[bool, t.Awaitable[bool]]]]
41
41
 
42
- DEFAULT_DENIAL_MESSAGE = "The user denied this tool call."
43
- CANCELLED_MESSAGE = "Tool call cancelled: the user moved on without approving it."
42
+ CANCELLED_MESSAGE = (
43
+ "CANCELLED: this tool call was NOT executed because the user moved on "
44
+ "without approving it. The action did not happen."
45
+ )
46
+
47
+ # Added to the system prompt when any tool needs approval, so the model
48
+ # knows how to treat denials instead of reporting success.
49
+ APPROVAL_SYSTEM_NOTE = (
50
+ "Some tools require human approval before they run. If a tool result "
51
+ "starts with 'DENIED' or 'CANCELLED', that action was NOT performed: never "
52
+ "say or imply it was done. Tell the user clearly which actions were not "
53
+ "carried out and why, and do not retry the same call unless the user asks."
54
+ )
55
+
56
+
57
+ def denial_message(tool_name: str, reason: t.Optional[str] = None) -> str:
58
+ """The tool result the model sees when a human rejects a call. Worded
59
+ bluntly on purpose: softer phrasing gets ignored by smaller models,
60
+ which then claim the action succeeded."""
61
+ msg = (
62
+ f"DENIED: the user rejected this call to '{tool_name}'. "
63
+ f"The action was NOT performed and nothing was changed."
64
+ )
65
+ if reason:
66
+ msg += f" Reason given by the user: {reason}"
67
+ return msg
68
+
69
+
70
+ def denial_reminder(denied_calls: list[str]) -> str:
71
+ """Transient note sent just before the model writes its next reply, so
72
+ it can't overlook denials. Not stored in memory or RunState."""
73
+ items = "\n".join(f"- {c}" for c in denied_calls)
74
+ return (
75
+ "[Automated notice from the agent runtime, not written by the user]\n"
76
+ "The user DENIED the following tool calls. They were NOT executed and "
77
+ "had NO effect:\n"
78
+ f"{items}\n"
79
+ "In your reply, state plainly that these actions were not performed. "
80
+ "Do not say or imply they were done, and do not retry them."
81
+ )
44
82
 
45
83
 
46
84
  @dataclass
@@ -323,14 +323,22 @@ class AgentTracer:
323
323
  duration: float,
324
324
  usage: dict,
325
325
  streamed: bool = False,
326
+ denied: t.Optional[list] = None,
326
327
  ) -> None:
328
+ """`denied` = names of tool calls a human rejected during this run."""
329
+ executed = tool_call_count - len(denied or [])
327
330
  stats = (
328
331
  f"{self._c(C.DIM)}⏱ {duration:.2f}s 🔁 {iterations} iteration(s) "
329
- f"🔧 {tool_call_count} tool call(s)"
332
+ f"🔧 {executed} tool call(s) run"
330
333
  )
331
334
  if usage and usage.get("total_tokens"):
332
335
  stats += f" 🎫 {usage['total_tokens']} tokens"
333
336
  stats += self._c(C.RESET)
337
+ if denied:
338
+ stats += (
339
+ f"\n{self._c(C.RED)}🚫 {len(denied)} denied (not executed): "
340
+ f"{', '.join(denied)}{self._c(C.RESET)}"
341
+ )
334
342
  self._print()
335
343
  # When streamed, the answer was already printed live above.
336
344
  lines = [stats] if streamed else [output, "", stats]
@@ -125,6 +125,49 @@ async def test_handler_denies_with_message(monkeypatch):
125
125
  assert "too risky" in tool_msgs[0]["content"]
126
126
 
127
127
 
128
+ @pytest.mark.asyncio
129
+ async def test_denial_is_explicit_and_system_prompt_explains_it(monkeypatch):
130
+ _script(monkeypatch, [DELETE, DONE])
131
+ result = await _agent(approval_handler=lambda req: False).arun("delete it")
132
+ tool_msg = next(m for m in result.messages if m["role"] == "tool")
133
+ assert tool_msg["content"].startswith("DENIED")
134
+ assert "NOT performed" in tool_msg["content"]
135
+ assert "DENIED" in result.messages[0]["content"] # system note added
136
+
137
+
138
+ @pytest.mark.asyncio
139
+ async def test_denial_reminder_sent_to_model_but_not_stored(monkeypatch):
140
+ sent = []
141
+ turns = [DELETE, DONE]
142
+
143
+ async def acompletion(**kwargs):
144
+ sent.append(list(kwargs["messages"]))
145
+ kind, payload = turns[len(sent) - 1]
146
+ if kind == "tools":
147
+ return _full(tool_calls=[_tc(*x) for x in payload], finish="tool_calls")
148
+ return _full(content=payload)
149
+
150
+ monkeypatch.setattr(llm_mod.litellm, "acompletion", acompletion)
151
+ agent = _agent(approval_handler=lambda req: False)
152
+ result = await agent.arun("delete it")
153
+
154
+ last_request = sent[-1][-1]
155
+ assert last_request["role"] == "user"
156
+ assert "NOT executed" in last_request["content"]
157
+ assert "delete_file" in last_request["content"]
158
+ # transient: not in the run's messages or the agent's memory
159
+ assert all("Automated notice" not in str(m.get("content")) for m in result.messages)
160
+ assert all("Automated notice" not in str(m.get("content")) for m in agent.memory.get())
161
+
162
+
163
+ @pytest.mark.asyncio
164
+ async def test_approved_result_is_tagged(monkeypatch):
165
+ _script(monkeypatch, [DELETE, DONE])
166
+ result = await _agent(approval_handler=lambda req: True).arun("delete it")
167
+ tool_msg = next(m for m in result.messages if m["role"] == "tool")
168
+ assert tool_msg["content"].startswith("[Approved by the user and executed]")
169
+
170
+
128
171
  @pytest.mark.asyncio
129
172
  async def test_async_handler_with_override_args(monkeypatch):
130
173
  _script(monkeypatch, [DELETE, DONE])
@@ -1,40 +0,0 @@
1
- import pytest
2
-
3
- from agentropic.tools import tool, ToolRegistry, Tool
4
-
5
-
6
- def test_tool_decorator_builds_schema():
7
- @tool()
8
- def greet(name: str, excited: bool = False) -> str:
9
- """Say hello to someone."""
10
- return f"Hello {name}!" + ("!!!" if excited else "")
11
-
12
- built: Tool = greet._tool
13
- assert built.name == "greet"
14
- assert "name" in built.parameters["properties"]
15
- assert "name" in built.parameters["required"]
16
- assert "excited" not in built.parameters["required"]
17
- assert built.call(name="Ada") == "Hello Ada!"
18
-
19
-
20
- def test_registry_openai_schema():
21
- @tool()
22
- def noop() -> str:
23
- """Does nothing."""
24
- return "ok"
25
-
26
- registry = ToolRegistry([noop])
27
- schema = registry.to_openai_tools()
28
- assert schema[0]["function"]["name"] == "noop"
29
- assert registry.get("noop") is not None
30
-
31
-
32
- @pytest.mark.asyncio
33
- async def test_async_tool_call():
34
- @tool()
35
- async def slow_add(a: int, b: int) -> int:
36
- """Add two numbers asynchronously."""
37
- return a + b
38
-
39
- result = await slow_add._tool.acall(a=2, b=3)
40
- assert result == 5
@@ -1,124 +0,0 @@
1
- """Streaming + tool-call loop tests using a fake LLM (no API key needed)."""
2
- import types
3
-
4
- import pytest
5
-
6
- from agentropic import Agent, StreamEvent, tool
7
- from agentropic import llm as llm_mod
8
-
9
-
10
- # -- fake litellm ---------------------------------------------------------------
11
-
12
- def _ns(**kw):
13
- return types.SimpleNamespace(**kw)
14
-
15
-
16
- def _tool_call(id_, name, args):
17
- return _ns(id=id_, function=_ns(name=name, arguments=args))
18
-
19
-
20
- def _full_response(content=None, tool_calls=None, finish="stop"):
21
- return _ns(
22
- choices=[_ns(message=_ns(content=content, tool_calls=tool_calls), finish_reason=finish)],
23
- usage=_ns(prompt_tokens=1, completion_tokens=1, total_tokens=2),
24
- )
25
-
26
-
27
- class _FakeStream:
28
- def __init__(self, chunks):
29
- self._chunks = list(chunks)
30
-
31
- def __aiter__(self):
32
- return self
33
-
34
- async def __anext__(self):
35
- if not self._chunks:
36
- raise StopAsyncIteration
37
- return self._chunks.pop(0)
38
-
39
-
40
- def _install_fake_llm(monkeypatch):
41
- """Turn 1: model calls get_weather. Turn 2: model answers in 3 tokens."""
42
- calls = {"n": 0}
43
-
44
- async def acompletion(**kwargs):
45
- calls["n"] += 1
46
- first = calls["n"] == 1
47
- if kwargs.get("stream"):
48
- if first:
49
- chunk = _ns(choices=[_ns(delta=_ns(content=None))], _final=_full_response(
50
- tool_calls=[_tool_call("c1", "get_weather", '{"city": "Tokyo"}')], finish="tool_calls"))
51
- return _FakeStream([chunk])
52
- tokens = ["It's ", "sunny ", "in Tokyo."]
53
- chunks = [_ns(choices=[_ns(delta=_ns(content=tok))]) for tok in tokens]
54
- chunks[-1]._final = _full_response(content="".join(tokens))
55
- return _FakeStream(chunks)
56
- if first:
57
- return _full_response(tool_calls=[_tool_call("c1", "get_weather", '{"city": "Tokyo"}')], finish="tool_calls")
58
- return _full_response(content="It's sunny in Tokyo.")
59
-
60
- def stream_chunk_builder(chunks, messages=None):
61
- return chunks[-1]._final
62
-
63
- monkeypatch.setattr(llm_mod.litellm, "acompletion", acompletion)
64
- monkeypatch.setattr(llm_mod.litellm, "stream_chunk_builder", stream_chunk_builder)
65
- return calls
66
-
67
-
68
- @tool()
69
- def get_weather(city: str) -> str:
70
- """Get the weather."""
71
- return f"sunny in {city}"
72
-
73
-
74
- def _agent():
75
- return Agent(name="t", model="fake/model", tools=[get_weather])
76
-
77
-
78
- # -- tests ---------------------------------------------------------------------
79
-
80
- @pytest.mark.asyncio
81
- async def test_arun_non_streaming_with_tools(monkeypatch):
82
- _install_fake_llm(monkeypatch)
83
- result = await _agent().arun("weather?")
84
- assert result == "It's sunny in Tokyo."
85
- assert result.tool_calls[0].name == "get_weather"
86
- assert result.tool_calls[0].result == "sunny in Tokyo"
87
- assert result.iterations == 2
88
-
89
-
90
- @pytest.mark.asyncio
91
- async def test_arun_stream_true_yields_events_and_runs_tools(monkeypatch):
92
- _install_fake_llm(monkeypatch)
93
- events: list[StreamEvent] = []
94
- async for ev in await _agent().arun("weather?", stream=True):
95
- events.append(ev)
96
-
97
- types_seen = [e.type for e in events]
98
- assert types_seen == ["tool_call", "tool_result", "text", "text", "text", "done"]
99
- assert events[0].tool_call.arguments == {"city": "Tokyo"}
100
- assert events[1].tool_call.result == "sunny in Tokyo"
101
- assert "".join(e.text for e in events if e.type == "text") == "It's sunny in Tokyo."
102
- assert events[-1].result.output == "It's sunny in Tokyo."
103
-
104
-
105
- @pytest.mark.asyncio
106
- async def test_astream_text_only(monkeypatch):
107
- _install_fake_llm(monkeypatch)
108
- text = "".join([tok async for tok in _agent().astream("weather?")])
109
- assert text == "It's sunny in Tokyo."
110
-
111
-
112
- def test_sync_run_stream(monkeypatch):
113
- _install_fake_llm(monkeypatch)
114
- events = list(_agent().run("weather?", stream=True))
115
- assert events[-1].type == "done"
116
- assert events[-1].result.output == "It's sunny in Tokyo."
117
-
118
-
119
- def test_result_is_json_serializable(monkeypatch):
120
- import json
121
-
122
- _install_fake_llm(monkeypatch)
123
- result = _agent().run("weather?")
124
- json.dumps(result.to_dict()) # tool_calls are plain dicts now
File without changes
File without changes
File without changes