Skip to content

Commit dac8e01

Browse files
committed
Add attachment support to chat and send_message APIs
1 parent ed6004a commit dac8e01

9 files changed

Lines changed: 321 additions & 51 deletions

File tree

.github/workflows/ci.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
workflow_call:
9+
10+
jobs:
11+
build:
12+
runs-on: ubuntu-latest
13+
14+
strategy:
15+
matrix:
16+
python-version: ["3.10", "3.11", "3.12", "3.13"]
17+
18+
steps:
19+
- uses: actions/checkout@v4
20+
21+
- name: Set up Python ${{ matrix.python-version }}
22+
uses: actions/setup-python@v5
23+
with:
24+
python-version: ${{ matrix.python-version }}
25+
26+
- name: Install build tools
27+
run: pip install build twine
28+
29+
- name: Install package
30+
run: pip install .
31+
32+
- name: Verify import
33+
run: python -c "from pine_ai import PineAI, AsyncPineAI, __version__; print(f'pine-ai {__version__} OK')"
34+
35+
- name: Build distribution
36+
run: python -m build
37+
38+
- name: Check distribution
39+
run: twine check dist/*

.github/workflows/publish.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: Publish to PyPI
2+
3+
on:
4+
push:
5+
tags: ["v*"]
6+
7+
jobs:
8+
ci:
9+
uses: ./.github/workflows/ci.yml
10+
11+
publish:
12+
needs: ci
13+
runs-on: ubuntu-latest
14+
permissions:
15+
contents: read
16+
id-token: write
17+
steps:
18+
- uses: actions/checkout@v4
19+
20+
- uses: actions/setup-python@v5
21+
with:
22+
python-version: "3.12"
23+
24+
- name: Install build tools
25+
run: pip install build
26+
27+
- name: Build distribution
28+
run: python -m build
29+
30+
- name: Publish to PyPI
31+
uses: pypa/gh-action-pypi-publish@release/v1

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,33 @@ pine sessions list # List sessions
3636
pine task start <session-id> # Start task (Pro)
3737
```
3838

39+
## Handling Events
40+
41+
Pine AI behaves like a human assistant. After you send a message, it sends
42+
acknowledgments, then work logs, then the real response (form, text, or task_ready).
43+
**Don't respond to acknowledgments** — only respond to forms, specific questions,
44+
and task lifecycle events, or you'll create an infinite loop.
45+
46+
## Continuing Existing Sessions
47+
48+
```python
49+
# List all sessions
50+
result = await client.sessions.list(limit=20)
51+
52+
# Continue an existing session
53+
await client.join_session(existing_session_id)
54+
history = await client.get_history(existing_session_id)
55+
async for event in client.chat(existing_session_id, "What is the status?"):
56+
...
57+
```
58+
59+
## Attachments
60+
61+
```python
62+
# Upload a document for dispute tasks
63+
attachments = await client.sessions.upload_attachment("bill.pdf")
64+
```
65+
3966
## Stream Buffering
4067

4168
Text streaming is buffered internally. You receive one merged text event,

RELEASING.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Releasing pine-ai (Python)
2+
3+
This package uses **PyPI trusted publishing** (OIDC) — no PyPI tokens are stored in GitHub.
4+
5+
## One-time setup
6+
7+
### Register the trusted publisher on PyPI
8+
9+
1. Go to <https://pypi.org/manage/project/pine-ai/settings/publishing/> (or add a **pending publisher** at <https://pypi.org/manage/account/publishing/> if the project doesn't exist on PyPI yet).
10+
2. Under **Add a new publisher**, fill in:
11+
- **Owner:** `RunVid`
12+
- **Repository:** `pine-python`
13+
- **Workflow name:** `publish.yml`
14+
3. Save.
15+
16+
## CI
17+
18+
CI runs automatically on every push to `main` and on pull requests.
19+
It installs, imports, builds, and verifies the package across Python 3.10 – 3.13.
20+
21+
## Publishing a new version
22+
23+
1. Bump the version in **both** places:
24+
- `pyproject.toml``version = "X.Y.Z"`
25+
- `src/pine_ai/__init__.py``__version__ = "X.Y.Z"`
26+
2. Commit, tag, and push:
27+
```bash
28+
git add -A && git commit -m "release: vX.Y.Z"
29+
git tag vX.Y.Z
30+
git push && git push --tags
31+
```
32+
3. The `publish.yml` workflow triggers on the `v*` tag, runs CI first, then publishes to PyPI.

src/pine_ai/chat.py

Lines changed: 90 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,24 @@
88
"""
99

1010
import asyncio
11-
from typing import Any, AsyncGenerator, Optional
11+
import time
12+
from typing import Any, AsyncGenerator, Callable, Coroutine, Optional
1213

1314
from pine_ai.models.events import C2SEvent, S2CEvent
1415
from pine_ai.transport.socketio import SocketIOManager
1516

16-
# Immediate-dispatch event types (Tier 2)
17-
IMMEDIATE_EVENTS = {
18-
S2CEvent.SESSION_STATE, S2CEvent.SESSION_INPUT_STATE, S2CEvent.SESSION_RICH_CONTENT,
19-
S2CEvent.SESSION_FORM_TO_USER,
20-
S2CEvent.SESSION_ASK_FOR_LOCATION, S2CEvent.SESSION_LOCATION_SELECTION,
21-
S2CEvent.SESSION_REWARD, S2CEvent.SESSION_PAYMENT, S2CEvent.SESSION_TASK_READY,
17+
TERMINAL_STATES = {"task_finished", "task_cancelled", "task_stale"}
18+
DEFAULT_IDLE_TIMEOUT_S = 120.0
19+
20+
# Events that are buffered/debounced — NOT dispatched immediately
21+
BUFFERED_EVENTS = {S2CEvent.SESSION_TEXT_PART, S2CEvent.SESSION_WORK_LOG_PART}
22+
23+
# Substantive response events — track for waiting_input termination gating
24+
SUBSTANTIVE_EVENTS = {
25+
S2CEvent.SESSION_TEXT, S2CEvent.SESSION_FORM_TO_USER,
26+
S2CEvent.SESSION_ASK_FOR_LOCATION, S2CEvent.SESSION_TASK_READY,
2227
S2CEvent.SESSION_TASK_FINISHED, S2CEvent.SESSION_INTERACTIVE_AUTH_CONFIRMATION,
23-
S2CEvent.SESSION_THREE_WAY_CALL, S2CEvent.SESSION_ERROR, S2CEvent.SESSION_THINKING,
24-
S2CEvent.SESSION_WORK_LOG, S2CEvent.SESSION_UPDATE_TITLE, S2CEvent.SESSION_TEXT,
25-
S2CEvent.SESSION_MESSAGE_STATUS, S2CEvent.SESSION_CARD, S2CEvent.SESSION_NEXT_TASKS,
26-
S2CEvent.SESSION_CONTINUE_IN_NEW_TASK, S2CEvent.SESSION_SOCIAL_SHARING,
27-
S2CEvent.SESSION_RETRY, S2CEvent.SESSION_DEBUG, S2CEvent.SESSION_ACTION_STATUS,
28-
S2CEvent.SESSION_COMPUTER_USE_INTERVENTION,
28+
S2CEvent.SESSION_THREE_WAY_CALL, S2CEvent.SESSION_REWARD,
2929
}
3030

3131

@@ -62,8 +62,15 @@ def collect(self, message_id: str, content: str, final: bool) -> Optional[str]:
6262

6363

6464
class ChatEngine:
65-
def __init__(self, sio: SocketIOManager):
65+
def __init__(
66+
self,
67+
sio: SocketIOManager,
68+
check_session_state: Optional[Callable[[str], Coroutine[Any, Any, dict[str, Any]]]] = None,
69+
idle_timeout_s: float = DEFAULT_IDLE_TIMEOUT_S,
70+
):
6671
self._sio = sio
72+
self._check_session_state = check_session_state
73+
self._idle_timeout_s = idle_timeout_s
6774

6875
async def join_session(self, session_id: str) -> dict[str, Any]:
6976
"""Join a session room — spec 5.1.1.
@@ -80,7 +87,12 @@ def leave_session(self, session_id: str) -> None:
8087
self._sio.emit(C2SEvent.SESSION_LEAVE, None, session_id)
8188

8289
async def chat(
83-
self, session_id: str, content: str,
90+
self,
91+
session_id: str,
92+
content: str,
93+
*,
94+
attachments: Optional[list[dict[str, Any]]] = None,
95+
referenced_sessions: Optional[list[dict[str, str]]] = None,
8496
) -> AsyncGenerator[ChatEvent, None]:
8597
"""Send a message and yield events with stream buffering.
8698
Production handler reads payload.data as {content, attachments, ...}.
@@ -90,22 +102,51 @@ async def chat(
90102
C2SEvent.SESSION_MESSAGE,
91103
{
92104
"content": content,
93-
"attachments": [],
94-
"referenced_sessions": [],
105+
"attachments": attachments or [],
106+
"referenced_sessions": referenced_sessions or [],
95107
"client_now_date": datetime.now().isoformat(),
96108
},
97109
session_id,
98110
)
99111
async for event in self._listen(session_id):
100112
yield event
101113

114+
def send_message(
115+
self,
116+
session_id: str,
117+
content: str,
118+
*,
119+
attachments: Optional[list[dict[str, Any]]] = None,
120+
referenced_sessions: Optional[list[dict[str, str]]] = None,
121+
) -> None:
122+
"""Fire-and-forget message send (no event listening)."""
123+
from datetime import datetime
124+
self._sio.emit(
125+
C2SEvent.SESSION_MESSAGE,
126+
{
127+
"content": content,
128+
"attachments": attachments or [],
129+
"referenced_sessions": referenced_sessions or [],
130+
"client_now_date": datetime.now().isoformat(),
131+
},
132+
session_id,
133+
)
134+
102135
async def _listen(self, session_id: str) -> AsyncGenerator[ChatEvent, None]:
103136
"""Listen for events with stream buffering."""
137+
# Check session state before entering loop — don't hang on completed sessions
138+
if self._check_session_state:
139+
try:
140+
session = await self._check_session_state(session_id)
141+
if session.get("state") in TERMINAL_STATES:
142+
yield ChatEvent(type=S2CEvent.SESSION_STATE, session_id=session_id, data={"content": session["state"]})
143+
return
144+
except Exception:
145+
pass # best effort
146+
104147
text_buffer = TextPartBuffer()
105148
queue: asyncio.Queue[Optional[ChatEvent]] = asyncio.Queue()
106149
done = False
107-
# Only terminate on waiting_input AFTER agent has sent substantive content.
108-
# The initial waiting_input (default state) arrives before agent starts.
109150
received_agent_response = False
110151

111152
# Work log debounce state
@@ -162,37 +203,41 @@ def handler(event: str, raw: dict[str, Any]) -> None:
162203
wl_timers[step_id] = loop.call_later(3.0, flush_wl, step_id)
163204
return
164205

165-
# Tier 2: immediate events
166-
if event in IMMEDIATE_EVENTS:
167-
nonlocal received_agent_response
168-
queue.put_nowait(ChatEvent(
169-
type=event, session_id=session_id,
170-
message_id=message_id, data=data, metadata=metadata,
171-
))
172-
# Track substantive agent responses
173-
if event in (
174-
S2CEvent.SESSION_TEXT, S2CEvent.SESSION_FORM_TO_USER,
175-
S2CEvent.SESSION_ASK_FOR_LOCATION, S2CEvent.SESSION_TASK_READY,
176-
S2CEvent.SESSION_TASK_FINISHED, S2CEvent.SESSION_INTERACTIVE_AUTH_CONFIRMATION,
177-
S2CEvent.SESSION_THREE_WAY_CALL, S2CEvent.SESSION_REWARD,
178-
):
179-
received_agent_response = True
180-
# Terminal conditions — only after agent has spoken
181-
if event == S2CEvent.SESSION_INPUT_STATE and isinstance(data, dict):
182-
if data.get("content") == "waiting_input" and received_agent_response:
183-
done = True
184-
queue.put_nowait(None)
185-
if event == S2CEvent.SESSION_STATE and isinstance(data, dict):
186-
state = data.get("content", "")
187-
if state in ("task_finished", "task_cancelled", "task_stale"):
188-
done = True
189-
queue.put_nowait(None)
206+
# All other events: dispatch immediately (pass-through for agent)
207+
nonlocal received_agent_response
208+
queue.put_nowait(ChatEvent(
209+
type=event, session_id=session_id,
210+
message_id=message_id, data=data, metadata=metadata,
211+
))
212+
if event in SUBSTANTIVE_EVENTS:
213+
received_agent_response = True
214+
if event == S2CEvent.SESSION_INPUT_STATE and isinstance(data, dict):
215+
if data.get("content") == "waiting_input" and received_agent_response:
216+
done = True
217+
queue.put_nowait(None)
218+
if event == S2CEvent.SESSION_STATE and isinstance(data, dict):
219+
state = data.get("content", "")
220+
if state in TERMINAL_STATES:
221+
done = True
222+
queue.put_nowait(None)
190223

191224
remove_handler = self._sio.add_event_handler(handler)
192225

193226
try:
194227
while not done:
195-
evt = await queue.get()
228+
try:
229+
evt = await asyncio.wait_for(queue.get(), timeout=self._idle_timeout_s)
230+
except asyncio.TimeoutError:
231+
# Idle timeout — check session state via REST
232+
if self._check_session_state:
233+
try:
234+
session = await self._check_session_state(session_id)
235+
if session.get("state") in TERMINAL_STATES:
236+
yield ChatEvent(type=S2CEvent.SESSION_STATE, session_id=session_id, data={"content": session["state"]})
237+
break
238+
except Exception:
239+
pass
240+
continue
196241
if evt is None:
197242
break
198243
yield evt

0 commit comments

Comments
 (0)