Skip to content

Commit 3245d97

Browse files
authored
Merge pull request #183 from nextcloud/feat/streaming
feat: Stream output text and tool calls
2 parents 376bc26 + a5badcb commit 3245d97

4 files changed

Lines changed: 331 additions & 54 deletions

File tree

.github/workflows/integration_test.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,42 @@ jobs:
285285
echo $TASK | jq '.ocs.data.task.output.sources'
286286
echo $TASK | jq '.ocs.data.task.output.sources' | grep -q 'get_folder_tree'
287287
288+
- name: Run streaming task
289+
if: ${{ matrix.server-versions == 'master' }}
290+
env:
291+
CREDS: "alice:alice"
292+
run: |
293+
TASK=$(curl -X POST -u "$CREDS" -H "oCS-APIRequest: true" -H "Content-type: application/json" \
294+
"http://localhost:8080/ocs/v2.php/taskprocessing/schedule?format=json" \
295+
--data-raw '{"input": {"input": "Count to 500", "confirmation":1, "conversation_token": ""},"type":"core:contextagent:interaction", "appId": "test", "customId": "", "preferStreaming": true}')
296+
echo "$TASK"
297+
TASK_ID=$(echo "$TASK" | jq '.ocs.data.task.id')
298+
299+
STREAMING_OBSERVED=0
300+
ELAPSED=0
301+
TASK_STATUS='"STATUS_SCHEDULED"'
302+
303+
until [ "$ELAPSED" -ge 80 ] || [ "$TASK_STATUS" = '"STATUS_SUCCESSFUL"' ] || [ "$TASK_STATUS" = '"STATUS_FAILED"' ]; do
304+
sleep 2
305+
ELAPSED=$((ELAPSED + 2))
306+
TASK=$(curl -u "$CREDS" -H "oCS-APIRequest: true" "http://localhost:8080/ocs/v2.php/taskprocessing/task/$TASK_ID?format=json")
307+
echo "$TASK"
308+
TASK_STATUS=$(echo "$TASK" | jq '.ocs.data.task.status')
309+
echo "Status: $TASK_STATUS"
310+
311+
if [ "$TASK_STATUS" = '"STATUS_RUNNING"' ]; then
312+
TASK_OUTPUT=$(echo "$TASK" | jq -r '.ocs.data.task.output.output // empty')
313+
if [ -n "$TASK_OUTPUT" ]; then
314+
echo "Streaming update observed: $TASK_OUTPUT"
315+
STREAMING_OBSERVED=1
316+
fi
317+
fi
318+
done
319+
320+
curl -u "$CREDS" -H "oCS-APIRequest: true" "http://localhost:8080/ocs/v2.php/taskprocessing/task/$TASK_ID?format=json"
321+
[ "$TASK_STATUS" = '"STATUS_SUCCESSFUL"' ]
322+
[ "$STREAMING_OBSERVED" -eq 1 ]
323+
288324
- name: Show nextcloud logs
289325
if: always()
290326
run: |

ex_app/lib/agent.py

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@
44
import os
55
import string
66
import random
7+
from collections.abc import Awaitable, Callable
78
from datetime import date
9+
from time import monotonic
10+
from typing import Any, cast
811

9-
from langchain_core.messages import ToolMessage, SystemMessage, AIMessage, HumanMessage
10-
from langchain_core.runnables import RunnableConfig, RunnableLambda
12+
from langchain_core.messages import ToolMessage, SystemMessage, AIMessage, HumanMessage, AIMessageChunk
13+
from langchain_core.runnables import RunnableConfig
1114
from nc_py_api import AsyncNextcloudApp
1215
from nc_py_api.ex_app import persistent_storage
1316

@@ -94,7 +97,11 @@ def export_conversation(checkpointer):
9497
conversation_token = add_signature(serialized_state.decode('utf-8'), key)
9598
return conversation_token
9699

97-
async def react(task, nc: AsyncNextcloudApp):
100+
async def react(
101+
task,
102+
nc: AsyncNextcloudApp,
103+
stream_output: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
104+
):
98105
safe_tools, dangerous_tools = await get_tools(nc)
99106

100107
model.bind_nextcloud(nc)
@@ -183,14 +190,62 @@ async def call_model(
183190
else:
184191
new_input = {"messages": [("user", task['input']['input'])]}
185192

186-
async for event in graph.astream(new_input, thread, stream_mode="values"):
193+
snapshot_messages = state_snapshot.values.get('messages', [])
194+
last_message: AIMessage = AIMessage("")
195+
if len(snapshot_messages) > 0:
196+
last_message = cast(AIMessage, snapshot_messages[-1])
197+
previous_message_count = len(snapshot_messages)
198+
source_list: list[str] = []
199+
known_sources: set[str] = set()
200+
streamed_output = ''
201+
last_stream_update = 0.0
202+
last_reported_stream_state: dict[str, Any] | None = None
203+
prefer_streaming = bool(task.get('preferStreaming'))
204+
stream_mode = ["messages", "values"] if prefer_streaming and stream_output is not None else "values"
205+
206+
async def report_stream_state(force: bool = False):
207+
nonlocal last_stream_update
208+
nonlocal last_reported_stream_state
209+
if stream_output is None:
210+
return
211+
stream_state = {'output': streamed_output, 'sources': json.dumps(source_list.copy())}
212+
if last_reported_stream_state == stream_state:
213+
return
214+
now = monotonic()
215+
if not force and last_reported_stream_state is not None and (now - last_stream_update) < 0.5:
216+
return
217+
await stream_output(stream_state)
218+
last_stream_update = now
219+
last_reported_stream_state = stream_state
220+
221+
async for event in graph.astream(new_input, thread, stream_mode=stream_mode):
222+
if isinstance(event, tuple):
223+
mode, payload = event
224+
else:
225+
mode, payload = "values", event
226+
227+
if mode == 'messages':
228+
message_chunk, metadata = payload
229+
if metadata.get('langgraph_node') != 'agent' or not isinstance(message_chunk, AIMessageChunk):
230+
continue
231+
chunk_content = message_chunk.content
232+
if isinstance(chunk_content, str) and chunk_content != '':
233+
streamed_output += chunk_content
234+
await report_stream_state()
235+
continue
236+
237+
event = payload
187238
last_message = event['messages'][-1]
188-
for message in event['messages']:
189-
if isinstance(message, HumanMessage):
190-
source_list = []
239+
for message in event['messages'][previous_message_count:]:
191240
if isinstance(message, AIMessage) and message.tool_calls:
192241
for tool_call in message.tool_calls:
193-
source_list.append(tool_call['name'])
242+
tool_name = tool_call['name']
243+
if tool_name not in known_sources:
244+
known_sources.add(tool_name)
245+
source_list.append(tool_name)
246+
await report_stream_state(force=True)
247+
248+
await report_stream_state(force=True)
194249

195250
state_snapshot = graph.get_state(thread)
196251
actions = ''

ex_app/lib/main.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,26 @@ async def handle_task(task, nc: AsyncNextcloudApp):
176176
nextcloud = AsyncNextcloudApp()
177177
if task['userId']:
178178
await nextcloud.set_user(task['userId'])
179-
output = await react(task, nextcloud)
179+
180+
stream_updates_enabled = task.get('preferStreaming', None) is True
181+
stream_update_failed = False
182+
183+
async def stream_output(intermediate_output):
184+
nonlocal stream_update_failed
185+
if not stream_updates_enabled or stream_update_failed:
186+
return
187+
try:
188+
await nc.ocs(
189+
"POST",
190+
f"/ocs/v2.php/taskprocessing/tasks_provider/{task['id']}/stream-result",
191+
json={"output": intermediate_output},
192+
)
193+
except (NextcloudException, RequestException) as stream_err:
194+
stream_update_failed = True
195+
tb_str = ''.join(traceback.format_exception(stream_err))
196+
await log(nc, LogLvl.WARNING, "Error streaming intermediate task result: " + tb_str)
197+
198+
output = await react(task, nextcloud, stream_output=stream_output if stream_updates_enabled else None)
180199
except Exception as e: # noqa
181200
try:
182201
tb_str = ''.join(traceback.format_exception(e))

0 commit comments

Comments
 (0)