Skip to content

Commit 39568c2

Browse files
feat(sdk): Tool.terminal flag + Agent._plan hook (0.13.0)
Two seams for deployments that need to extend the pipeline without forking the SDK: - Tool.terminal: marks a tool as turn-completing. The Generator's tool loop breaks after a successful terminal call so the LLM cannot re-emit the same write (e.g. comment_on_post) across subsequent rounds. Fixes the duplicate-reply class of bugs where one mention produced 2-3 copies of the same comment. - Agent._plan hook: symmetric with _perceive/_enrich. Default delegates to planner.plan (the SDK rule engine). Subclasses override to layer per-user or per-deployment rules on top while keeping Psyche + Thronglets-driven decisions intact. run_pipeline takes an optional plan_fn callable; base.Agent wires it to self._plan in process(). Pure additions — existing callers are unaffected. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b1fbdcb commit 39568c2

6 files changed

Lines changed: 388 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,43 @@
11
# Changelog
22

3+
## [0.13.0] - 2026-04-12
4+
5+
### Added
6+
7+
- **`Tool.terminal` flag** — write-side actions (social posts, comment
8+
replies, anything that completes a turn) can be marked terminal at
9+
registration time:
10+
11+
registry.register("comment_on_post", schema, handler, terminal=True)
12+
13+
``ToolRegistry.is_terminal(name)`` exposes the flag for the tool
14+
loop. Default is ``False``; existing tools register unchanged.
15+
16+
- **`Agent._plan` hook** — symmetric with `_perceive`, `_enrich`,
17+
`_build_prompt`, etc. Default delegates to ``planner.plan`` so the
18+
built-in Psyche/Thronglets-driven Plan stays the baseline. Subclasses
19+
override to layer deployment-specific rules on top — for example
20+
``oasyce-samantha 0.2.0`` uses this seam for per-user standing
21+
rules without touching the SDK Planner.
22+
23+
- **`run_pipeline(plan_fn=...)` parameter** — accepts a custom
24+
Plan-producing callable. ``None`` falls back to the SDK default.
25+
This is the pipeline-side counterpart of ``Agent._plan``.
26+
27+
### Fixed
28+
29+
- **Tool loop no longer duplicates terminal calls.** ``Agent._generate``
30+
now ends the turn after a successful terminal-tool batch, instead of
31+
giving the LLM another round to re-emit the same write. This was the
32+
root cause of Samantha posting 2-3 duplicate comments on a single
33+
``mention`` with an image attached: vision models occasionally
34+
re-evaluate after a tool call and re-issue the same ``comment_on_post``
35+
in round 2/3 of the loop.
36+
37+
The fix is opt-in via ``Tool.terminal=True`` — non-terminal read
38+
tools (memory recall, balance query) still chain freely so the LLM
39+
can synthesise a final answer from a tool result.
40+
341
## [0.12.0] - 2026-04-11
442

543
### Removed

oasyce_sdk/agent/base.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
Overridable hooks (all prefixed with ``_``):
2525
2626
``_perceive`` : stimulus → Perception (default: identity.perceive)
27+
``_plan`` : (stimulus, perception) → Plan (default: planner.plan)
2728
``_enrich`` : Plan-driven context gathering (default: images only)
2829
``_build_prompt`` : format the stimulus for the LLM (default: raw content)
2930
``_get_llm`` : pick an LLM slot for this stimulus (default: registry)
@@ -47,7 +48,7 @@
4748

4849
from .channel import Channel
4950
from .pipeline import EnrichContext, run_pipeline
50-
from .planner import Plan
51+
from .planner import Plan, plan as default_plan
5152
from .stimulus import Stimulus
5253
from .tools import ToolContext, ToolRegistry
5354

@@ -135,6 +136,7 @@ def process(self, stimulus: Stimulus) -> str | None:
135136
reflect=self._reflect,
136137
constitution=self.constitution,
137138
tool_registry=self._tools,
139+
plan_fn=self._plan,
138140
)
139141

140142
def close(self) -> None:
@@ -164,6 +166,24 @@ def _perceive(self, stimulus: Stimulus) -> "Perception":
164166
context = f"{stimulus.kind}: {stimulus.content[:200]}"
165167
return self.identity.perceive(context)
166168

169+
def _plan(self, stimulus: Stimulus, perception: "Perception") -> Plan:
170+
"""(Stimulus, Perception) → Plan. Pure, zero cost.
171+
172+
Default delegates to ``planner.plan`` — the SDK rule engine
173+
driven by Psyche ResponseContract + Thronglets ambient priors.
174+
Subclasses override to layer their own rules on top, e.g.::
175+
176+
def _plan(self, stimulus, perception):
177+
p = super()._plan(stimulus, perception)
178+
if stimulus.kind == "chat" and stimulus.sender_id:
179+
self.session(stimulus.sender_id).rules.apply(stimulus, p)
180+
return p
181+
182+
The hook receives both stimulus and perception so user-rule
183+
layers can match on text *and* on Psyche state.
184+
"""
185+
return default_plan(stimulus, perception)
186+
167187
def _enrich(self, stimulus: Stimulus, plan: Plan) -> EnrichContext:
168188
"""Gather per-stimulus context. Default: images only.
169189
@@ -222,8 +242,14 @@ def _generate(
222242
1. Call LLM with ``messages`` + ``tools``
223243
2. If no tool calls, return the text
224244
3. Else execute each tool call via ``self._tools.execute``,
225-
append results as messages, and loop
226-
4. Bail after ``TOOL_LOOP_MAX_ROUNDS`` iterations
245+
append results as messages
246+
4. If any tool call in this round was *terminal* (a write-side
247+
action that completes the turn — social posts, replies),
248+
break the loop. The LLM should not get a second chance to
249+
re-emit the same write on the same turn — that is exactly
250+
how a single ``mention`` ended up producing two or three
251+
duplicate comments.
252+
5. Otherwise loop, bailing after ``TOOL_LOOP_MAX_ROUNDS``
227253
228254
On LLM exception while images are in the prompt, retry once
229255
text-only — a common OpenAI-compat failure mode where the
@@ -248,6 +274,7 @@ def _generate(
248274
raise
249275
if not resp.tool_calls:
250276
return resp.text
277+
terminal_called = False
251278
for tc in resp.tool_calls:
252279
self._inject_tool_defaults(tc, stimulus)
253280
result = self._tools.execute(tc.name, tc.arguments, tool_ctx)
@@ -260,6 +287,14 @@ def _generate(
260287
"role": "user",
261288
"content": f"[Tool result for {tc.name}]: {result}",
262289
})
290+
if self._tools.is_terminal(tc.name):
291+
terminal_called = True
292+
if terminal_called:
293+
logger.info(
294+
"%s terminal tool fired, ending turn after this round",
295+
stimulus.kind,
296+
)
297+
return resp.text
263298
return resp.text if resp else ""
264299

265300
def _inject_tool_defaults(

oasyce_sdk/agent/pipeline.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
from .context import ConversationMessage, build_messages
2020
from .evaluator import evaluate as evaluate_response
21-
from .planner import Plan, plan as make_plan
21+
from .planner import Plan, plan as default_plan
2222

2323
if TYPE_CHECKING:
2424
from .llm import LLMProvider
@@ -63,6 +63,7 @@ def run_pipeline(
6363
reflect: Callable[["Stimulus", str, "Perception"], None],
6464
constitution: str,
6565
tool_registry: "ToolRegistry",
66+
plan_fn: Callable[["Stimulus", "Perception"], Plan] | None = None,
6667
) -> str | None:
6768
"""Run Perceive → Plan → Enrich → Generate → Evaluate → Deliver → Reflect.
6869
@@ -71,6 +72,10 @@ def run_pipeline(
7172
7273
The Plan is derived from the Perception — Psyche ResponseContract +
7374
Thronglets ambient priors — and then refined by GenerationControls.
75+
Deployments can pass a custom ``plan_fn`` callable to layer their
76+
own rules (e.g. user-defined standing instructions) on top of the
77+
built-in rule engine; ``None`` falls back to ``planner.plan`` —
78+
the SDK's pure rule engine.
7479
"""
7580
logger.info(
7681
"pipeline: %s sender=%s post=%s",
@@ -81,7 +86,7 @@ def run_pipeline(
8186
perception = perceive(stimulus)
8287

8388
# 2. Plan — zero cost, rule engine driven by Psyche + Thronglets
84-
plan = make_plan(stimulus, perception)
89+
plan = (plan_fn or default_plan)(stimulus, perception)
8590

8691
# Psyche GenerationControls are hard limits layered over the Plan
8792
gc = perception.generation_controls

oasyce_sdk/agent/tools.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,23 @@ class ToolContext:
7373

7474
@dataclass
7575
class Tool:
76-
"""One tool: a schema the LLM sees, and a handler we execute."""
76+
"""One tool: a schema the LLM sees, and a handler we execute.
77+
78+
Fields:
79+
name, schema, handler — the dispatch triple.
80+
terminal — write-side actions that complete the turn. The
81+
Generator's tool loop breaks immediately after a successful
82+
terminal call, preventing the LLM from re-emitting the same
83+
action across rounds (e.g. posting the same comment 2-3 times
84+
on a single mention). Read tools (memory recall, balance query)
85+
stay non-terminal so the LLM can synthesise a final answer
86+
from the result.
87+
"""
7788

7889
name: str
7990
schema: dict[str, Any]
8091
handler: Callable[[dict[str, Any], ToolContext], str]
92+
terminal: bool = False
8193

8294

8395
class ToolRegistry:
@@ -91,8 +103,12 @@ def register(
91103
name: str,
92104
schema: dict[str, Any],
93105
handler: Callable[[dict[str, Any], ToolContext], str],
106+
*,
107+
terminal: bool = False,
94108
) -> None:
95-
self._tools[name] = Tool(name=name, schema=schema, handler=handler)
109+
self._tools[name] = Tool(
110+
name=name, schema=schema, handler=handler, terminal=terminal,
111+
)
96112

97113
@property
98114
def definitions(self) -> list[dict[str, Any]]:
@@ -104,6 +120,15 @@ def select(self, names: list[str] | None) -> list[dict[str, Any]]:
104120
return self.definitions
105121
return [t.schema for t in self._tools.values() if t.name in names]
106122

123+
def is_terminal(self, name: str) -> bool:
124+
"""Whether ``name`` is a terminal (turn-completing) tool.
125+
126+
Unknown names return False — the loop continues, the unknown-tool
127+
error is surfaced to the LLM via ``execute``.
128+
"""
129+
tool = self._tools.get(name)
130+
return bool(tool and tool.terminal)
131+
107132
def execute(
108133
self,
109134
name: str,

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "oasyce-sdk"
7-
version = "0.12.0"
7+
version = "0.13.0"
88
description = "Python SDK for Oasyce -- On-chain economic system for AI agents: escrow, service marketplace, data rights, reputation, dispute resolution"
99
readme = "README.md"
1010
requires-python = ">=3.10"

0 commit comments

Comments
 (0)