Skip to content

Commit e4a11b5

Browse files
committed
refactor: use invoke for API calls and tool execution in ai_shell example
Replace manual `raise_()` routing with `invoke` handlers in the `thinking` and `using_tool` states. The invoke feature runs I/O work in background threads with automatic `done.invoke` event routing, which is a better fit for these states and showcases the feature in a practical example. Also add a warning/disclaimer about LLM shell access risks, and sync the main loop with a threading.Event to wait for invoke completion before re-prompting.
1 parent 49ddc4c commit e4a11b5

1 file changed

Lines changed: 44 additions & 29 deletions

File tree

tests/examples/ai_shell_machine.py

Lines changed: 44 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,22 @@
77
A standalone interactive CLI that uses the OpenAI SDK for LLM calls with
88
tool_use. Demonstrates **parallel states**, **compound states**,
99
**HistoryState**, **eventless transitions**, **In() guards**,
10-
**done.state**, **error.execution**, and **raise_()** — all working
11-
together in a practical application.
10+
**done.state**, **error.execution**, **invoke**, and **raise_()** — all
11+
working together in a practical application.
12+
13+
.. warning::
14+
15+
This example grants an LLM the ability to read files, list directories,
16+
and execute shell commands — which can be very useful for exploring a
17+
codebase, running tests, or automating tasks. However, the actual behavior
18+
depends on the prompts you send and the model you use, and unintended
19+
actions (e.g., deleting files or exposing credentials) are possible.
20+
21+
**Use at your own risk.** This code is provided for educational and
22+
demonstration purposes only. The authors and contributors of
23+
python-statemachine accept no liability for any damage or data loss.
24+
Consider running it in an isolated environment (e.g., a container or
25+
virtual machine) and avoid using elevated privileges.
1226
1327
Usage::
1428
@@ -329,17 +343,17 @@ class AIShell(StateChart):
329343
"""An agentic coding assistant as a StateChart.
330344
331345
Demonstrates parallel states, compound states, HistoryState, eventless
332-
transitions, In() guards, done.state, error.execution, and raise_() —
333-
all in a practical application.
346+
transitions, In() guards, done.state, error.execution, invoke, and
347+
raise_() — all in a practical application.
334348
335349
States::
336350
337351
session (Parallel)
338352
├── conversation (Compound)
339353
│ ├── idle (initial)
340354
│ ├── processing (Compound)
341-
│ │ ├── thinking (initial) ← API call + spinner
342-
│ │ ├── using_tool
355+
│ │ ├── thinking (initial, invoke) ← API call + spinner
356+
│ │ ├── using_tool (invoke) ← tool execution
343357
│ │ ├── done (final)
344358
│ │ └── h = HistoryState(deep) ← for error retry
345359
│ ├── responding
@@ -367,12 +381,11 @@ class processing(State.Compound):
367381
done = State("Done", final=True)
368382
h = HistoryState(type="deep")
369383

370-
# Internal events route thinking results
371-
use_tool = thinking.to(using_tool)
372-
text_ready = thinking.to(done)
373-
374-
# Tool execution → think again (via raise_())
375-
think_again = using_tool.to(thinking)
384+
# Invoke results route automatically
385+
done_invoke_thinking = thinking.to(
386+
using_tool, cond="has_tool_calls"
387+
) | thinking.to(done)
388+
done_invoke_using_tool = using_tool.to(thinking)
376389

377390
responding = State("Responding")
378391
recovering = State("Recovering")
@@ -414,6 +427,7 @@ def __init__(self):
414427
self.messages: list = [{"role": "system", "content": SYSTEM_PROMPT}]
415428
self._last_text: str = ""
416429
self._retries: int = 0
430+
self._ready = threading.Event()
417431
super().__init__()
418432

419433
# --- Guards ---
@@ -431,19 +445,23 @@ def cannot_retry(self, **kwargs) -> bool:
431445
return not self.can_retry()
432446

433447
def is_active_context(self, **kwargs) -> bool:
434-
return len(self.messages) >= 3
448+
return len(self.messages) >= 5
435449

436450
def is_deep_context(self, **kwargs) -> bool:
437-
return len(self.messages) >= 5
451+
return len(self.messages) >= 20
438452

439453
# --- Callbacks ---
440454

441455
def on_user_message(self, text, **kwargs):
442456
"""Append the user's message to conversation history."""
443457
self.messages.append({"role": "user", "content": text})
444458

445-
def on_enter_thinking(self, **kwargs):
446-
"""Call the OpenAI API with a spinner animation. Route to tool use or done."""
459+
def has_tool_calls(self, data=None, **kwargs) -> bool:
460+
"""Guard: check if the API response contains tool calls."""
461+
return bool(getattr(data, "tool_calls", None))
462+
463+
def on_invoke_thinking(self, **kwargs):
464+
"""Call the OpenAI API with a spinner animation. Returns the message."""
447465
with Spinner():
448466
response = self.client.chat.completions.create(
449467
model="gpt-4o-mini",
@@ -452,21 +470,16 @@ def on_enter_thinking(self, **kwargs):
452470
)
453471

454472
message = response.choices[0].message
455-
tool_calls = message.tool_calls
456-
457-
# Store the assistant response in history
458473
self.messages.append(message)
459474

460-
if tool_calls:
461-
self.raise_("use_tool", tool_calls=list(tool_calls))
462-
else:
475+
if not message.tool_calls:
463476
self._last_text = message.content or ""
464-
# Enter the final child → triggers done.state.processing automatically
465-
self.raise_("text_ready")
466477

467-
def on_enter_using_tool(self, tool_calls, **kwargs):
468-
"""Execute tool calls and send results back for another thinking round."""
469-
for call in tool_calls:
478+
return message
479+
480+
def on_invoke_using_tool(self, data, **kwargs):
481+
"""Execute tool calls from the API response."""
482+
for call in data.tool_calls:
470483
args = json.loads(call.function.arguments)
471484
print(f" [tool] {call.function.name}({json.dumps(args)})")
472485
result = execute_tool(call.function.name, args, sm=self)
@@ -477,7 +490,6 @@ def on_enter_using_tool(self, tool_calls, **kwargs):
477490
"content": result,
478491
}
479492
)
480-
self.raise_("think_again")
481493

482494
def on_enter_responding(self, **kwargs):
483495
"""Print the assistant's text response."""
@@ -486,8 +498,9 @@ def on_enter_responding(self, **kwargs):
486498
self._last_text = ""
487499

488500
def on_enter_idle(self, **kwargs):
489-
"""Reset retry counter when returning to idle."""
501+
"""Reset retry counter and signal readiness when returning to idle."""
490502
self._retries = 0
503+
self._ready.set()
491504

492505
def on_enter_recovering(self, **kwargs):
493506
"""Handle API errors with retry logic (via error.execution)."""
@@ -540,6 +553,8 @@ def main():
540553
sys.exit(f"Error initializing: {e}")
541554

542555
while not sm.is_terminated:
556+
sm._ready.wait()
557+
sm._ready.clear()
543558
try:
544559
text = input("> ")
545560
except (EOFError, KeyboardInterrupt):

0 commit comments

Comments
 (0)