feat: inference generates messages, simplify inference run events, fix encoding bug

This commit is contained in:
Shivering Ocelot committed 2026-10-03 11:46:16 -05:00
1 parent ee323fe660
commit 868e9b0545
5 files changed
+258 -309

No files matched your search

+19 -38
View File
@@ -144,13 +144,28 @@ class Agent:
}, },
) )
async def on_output_event(stream): async def on_inference(msg):
print("[agent] run output " + repr(stream)) kwargs = {'content': msg.content}
if msg.role == 'assistant':
# kind and subkind are default
pass
elif msg.role == 'reasoning':
kwargs['kind'] = "log"
kwargs['subkind'] = "reasoning"
elif msg.role == 'tool':
kwargs['kind'] = "log"
kwargs['subkind'] = "tool-call"
kwargs['content'] = repr(msg.content)
else:
# Unknown message
kwargs['kind'] = "log"
new_message = await self.app.send_chat_message( new_message = await self.app.send_chat_message(
sender=self.id, sender=self.id,
chat_id=message.chat_id, chat_id=message.chat_id,
thread_id=message.thread_id, thread_id=message.thread_id,
content=stream, **kwargs
) )
run.emit_event('chat-message', { run.emit_event('chat-message', {
'messageId': new_message['id'], 'messageId': new_message['id'],
@@ -158,41 +173,7 @@ class Agent:
'threadId': message.thread_id, 'threadId': message.thread_id,
}) })
async def on_reasoning_event(stream): await run.on_async('inference', on_inference)
print("[agent] run output " + repr(stream))
new_message = await self.app.send_chat_message(
sender=self.id,
chat_id=message.chat_id,
thread_id=message.thread_id,
content=stream,
kind="log",
subkind="reasoning",
)
run.emit_event('chat-message', {
'messageId': new_message['id'],
'chatId': message.chat_id,
'threadId': message.thread_id,
})
async def on_toolcall_event(stream):
print("[agent] run output " + repr(stream))
new_message = await self.app.send_chat_message(
sender=self.id,
chat_id=message.chat_id,
thread_id=message.thread_id,
content=stream,
kind="log",
subkind="tool-call",
)
run.emit_event('chat-message', {
'messageId': new_message['id'],
'chatId': message.chat_id,
'threadId': message.thread_id,
})
await run.on_async('reasoning', on_reasoning_event)
await run.on_async('output', on_output_event)
await run.on_async('tool-call', on_toolcall_event)
await self.run(run) await self.run(run)
return run return run
+12 -36
View File
@@ -20,47 +20,25 @@ async def chat_completion_loop(app, run, messages, model_id=None, tools=None):
tools = list(tools or []) tools = list(tools or [])
execute_tools = run.input.get('tool_policy', 'auto') == 'auto' execute_tools = run.input.get('tool_policy', 'auto') == 'auto'
tool_calls = []
output_stream = None
reasoning_stream = None
inference_end = asyncio.Event()
def on_output(stream):
nonlocal output_stream
output_stream = stream
def on_reasoning(stream):
nonlocal reasoning_stream
reasoning_stream = stream
run.on('output', on_output)
run.on('reasoning', on_reasoning)
run.on('tool-call', tool_calls.append)
run.on('inference-end', lambda _: inference_end.set())
while True: while True:
# Reset per-iteration state tool_calls = []
output_stream = None turn_response = False
reasoning_stream = None generator = inference.chat_completion(
inference_end.clear()
await inference.chat_completion(
app, app,
model_id=model_id, model_id=model_id,
messages=messages, messages=messages,
tools=tools, tools=tools,
agent_run=run, opts=run.input,
) )
await inference_end.wait() async for msg in generator:
messages.append(msg)
if msg.role == 'tool':
tool_calls.append(msg.content)
if msg.role == 'assistant':
turn_response = True
content = output_stream and await output_stream.content_wait() run.emit_event('inference', msg)
reasoning = reasoning_stream and await reasoning_stream.content_wait()
if content:
messages.append(inference.Message('assistant', content))
if reasoning:
messages.append(inference.Message('reasoning', reasoning))
if execute_tools and tool_calls: if execute_tools and tool_calls:
for tc in tool_calls: for tc in tool_calls:
@@ -69,9 +47,7 @@ async def chat_completion_loop(app, run, messages, model_id=None, tools=None):
print(f"[agent] unknown tool name: {tc.tool_name}") print(f"[agent] unknown tool name: {tc.tool_name}")
continue continue
tc.result = await tool.execute_async(**tc.arguments) tc.result = await tool.execute_async(**tc.arguments)
messages.append(inference.Message('tool', tc)) elif turn_response:
tool_calls.clear()
elif content or not reasoning or not execute_tools:
break break
run.close() run.close()
+218 -231
View File
@@ -4,15 +4,28 @@ import json
import os import os
import traceback import traceback
import random import random
import inspect
from stream import Stream from stream import Stream
from tool import Tool, ToolSpec, ToolCall from tool import Tool, ToolSpec, ToolCall
#from llama_cpp import Llama #from llama_cpp import Llama
from openrouter import OpenRouter from openrouter import OpenRouter
TEST_MOCK = False
VALID_ROLES = ['user', 'assistant', 'system', 'tool', 'reasoning'] VALID_ROLES = ['user', 'assistant', 'system', 'tool', 'reasoning']
CONTEXT_WINDOW = 10_000 CONTEXT_WINDOW = 10_000
# Background inference tasks are kept referenced here so they aren't GC'd mid-flight.
_background_tasks = set()
def spawn(coro):
task = asyncio.create_task(coro)
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
return task
# These reflect "emitted messages" from any actor, individual turn parts, rather than entire turns.
# role acts as a type and a sender simultaneously, and content is dynamic
class Message: class Message:
def __init__(self, role, content): def __init__(self, role, content):
if role not in VALID_ROLES: if role not in VALID_ROLES:
@@ -21,11 +34,7 @@ class Message:
self.content = content self.content = content
def to_dict(self): def to_dict(self):
"""Serialize to the dict format expected by llama-cpp / ollama.""" return {"role": self.role, "content": self.content}
d = {"role": self.role, "content": self.content}
if self.reasoning:
d["reasoning"] = self.reasoning
return d
def __repr__(self): def __repr__(self):
return f"Message(role={self.role!r}, content={self.content!r})" return f"Message(role={self.role!r}, content={self.content!r})"
@@ -42,7 +51,6 @@ def format_tools(tools):
return specs return specs
def format_openai(messages): def format_openai(messages):
print('format openai ' + repr(messages))
formatted = [] formatted = []
def last_assistant_message(): def last_assistant_message():
if formatted and formatted[-1]['role'] == 'assistant': if formatted and formatted[-1]['role'] == 'assistant':
@@ -103,9 +111,12 @@ def safe_queue(callback):
queue = asyncio.Queue() queue = asyncio.Queue()
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
async def read_loop(): async def read_loop():
nonlocal callback
while True: while True:
args = await queue.get() args = await queue.get()
await callback(*args) task = callback(*args)
if inspect.isawaitable(task):
await task
loop.create_task(read_loop()) loop.create_task(read_loop())
def send(*args): def send(*args):
@@ -173,232 +184,200 @@ async def chat_completion(app,
messages=None, messages=None,
tools=[], tools=[],
opts={}, opts={},
agent_run=None
): ):
# TESTING OVERRIDE
# return await mock_completion(app, agent_run)
db_model = (await app.get_model(model_id)).value db_model = (await app.get_model(model_id)).value
model_engine = db_model['engine'] model_engine = db_model['engine']
model_name = db_model['model'] model_name = db_model['model']
if model_engine == "llama-cpp": print(f"[inference] chat completion: {model_engine} '{model_name}', {len(messages)} messages, {len(tools)} tools")
result = await local_chat_completion(app, messages, model=model_name, tools=tools, opts=opts, agent_run=agent_run)
elif model_engine == "openrouter":
result = await openrouter_chat_completion(app, messages, model=model_name, tools=tools, opts=opts, agent_run=agent_run)
else:
raise Exception("Unsupported model: " + repr(model))
if agent_run: if TEST_MOCK:
return None generator = mock_completion(app)
return result elif model_engine == "llama-cpp":
generator = local_chat_completion(app, messages, model=model_name, tools=tools, opts=opts)
elif model_engine == "openrouter":
generator = openrouter_chat_completion(app, messages, model=model_name, tools=tools, opts=opts)
else:
raise Exception("Unsupported model engine: " + repr(model_engine))
async for message in generator:
yield message
print(f"[inference] chat completion end: {model_engine} '{model_name}'")
# llama.cpp # llama.cpp
async def local_chat_completion(app, messages, model, tools=[], opts={}, agent_run=None): async def local_chat_completion(app, messages, model, tools=[], opts={}):
if not agent_run: queue = asyncio.Queue()
raise Exception("agent_run required") loop = asyncio.get_running_loop()
emit = safe_queue(queue.put_nowait)
content_stream = None
async def message_loop(name, value=None):
nonlocal content_stream, agent_run
if name == 'output':
if content_stream == None:
content_stream = Stream(app)
agent_run.emit_event('output', content_stream)
content_stream.feed(value)
elif name == 'close':
if content_stream:
content_stream.close()
agent_run.emit_event('inference-end', {})
send_message = safe_queue(message_loop)
def worker():
llm = get_llama_cpp_model(app, model)
kwargs = {}
temp = opts.get("temperature")
if temp is not None:
kwargs['temperature'] = temp
repeat = opts.get("repetition_penalty")
if repeat is not None:
kwargs['repeat_penalty'] = repeat
# Format tools for llama-cpp (OpenAI-compatible function format)
formatted_tools = format_tools(tools) if tools else None
if formatted_tools:
kwargs['tools'] = formatted_tools
kwargs['tool_choice'] = 'auto'
# With stream=True, returns an iterator that infers tokens on demand (openai compatible)
llm_output = llm.create_chat_completion(
messages=[msg.to_dict() for msg in messages],
stream=True,
**kwargs
)
for chunk in llm_output:
# Check for cancellation
if agent_run and agent_run.cancelled:
print(f"Cancelling local inference for run {agent_run.id}")
break
delta = chunk['choices'][0]['delta']
if 'content' in delta:
send_message('output', delta['content'])
# Handle tool_call deltas if the model emits them
if 'tool_calls' in delta:
# Forward tool call chunks as JSON strings for the consumer
send_message("tool-call", {
"id": tc.get("id"),
"function": tc.get("function"),
})
send_message('close')
thread = threading.Thread(target=worker)
if agent_run:
agent_run.thread = thread
thread.start()
if agent_run:
return None
return content_stream
async def openrouter_chat_completion(app, messages, model, tools=[], opts={}, agent_run=None):
if not agent_run:
raise Exception("agent_run required")
# TODO: Separate as InferenceRun class
content_stream = None
reasoning_stream = None
last_channel = None
def open_channel(channel):
nonlocal last_channel, content_stream, reasoning_stream
if channel != last_channel:
if content_stream: content_stream.close()
if reasoning_stream: reasoning_stream.close()
content_stream = None
reasoning_stream = None
last_channel = channel
async def message_loop(name, value=None):
nonlocal content_stream, reasoning_stream, agent_run
open_channel(name) # Hack: use the message as the channel name
if name == 'output':
if content_stream == None:
content_stream = Stream(app)
agent_run.emit_event('output', content_stream)
content_stream.feed(value)
if name == 'reasoning':
if reasoning_stream == None:
reasoning_stream = Stream(app)
agent_run.emit_event('reasoning', reasoning_stream)
reasoning_stream.feed(value)
if name == 'tool-call':
agent_run.emit_event('tool-call', value)
elif name == 'close':
if content_stream: content_stream.close()
if reasoning_stream: reasoning_stream.close()
agent_run.emit_event('inference-end', {})
emit = safe_queue(message_loop)
async def worker(): async def worker():
print("START OPENROUTER INFERENCE") stream = None
if not app.openrouter_client:
raise Exception("OPENROUTER_API_KEY environment variable is not set")
kwargs = {
'model': model,
'messages': format_openai(messages),
'stream': True,
# Options
'timeout_ms': 45_000,
'retries': 3,
}
# Pass tools in OpenAI-compatible format if provided
formatted_tools = format_tools(tools) if tools else None
if formatted_tools:
kwargs['tools'] = formatted_tools
print(f"Tools: {tools} and {formatted_tools}")
temp = agent_run.input.get("temperature")
if temp is not None:
kwargs['temperature'] = temp
reasoning = agent_run.input.get("reasoning_effort")
if reasoning is not None:
kwargs['reasoning'] = {"effort": reasoning}
stream_response = await app.openrouter_client.chat.send_async(**kwargs)
# OpenAI format streams tool calls in parts
# We could, but I won't assume any tool call is complete until the message is over
tool_calls = []
async for event in stream_response:
if agent_run and agent_run.cancelled:
stream_response.cancel()
break
if event.choices and len(event.choices) > 0:
delta = event.choices[0].delta
if delta.content:
emit('output', delta.content)
if delta.reasoning:
emit('reasoning', delta.reasoning)
if delta.tool_calls:
print("[openrouter] tool call:" + repr(delta.tool_calls))
for tc in delta.tool_calls:
if tc.index >= len(tool_calls):
tool_calls.append(ToolCall(tool_name="", arguments="", id=""))
tool_call = tool_calls[tc.index]
if tc.id:
tool_call.id += tc.id
if tc.function:
if tc.function.name:
tool_call.tool_name += tc.function.name
if tc.function.arguments:
tool_call.arguments += tc.function.arguments
else:
print("[openrouter] no event choices: " + repr(event))
for tc in tool_calls:
try:
arguments = json.loads(tc.arguments)
tc.arguments = arguments
emit('tool-call', tc)
except Exception:
print("[openrouter] Bad tool call arguments for " + repr(tc))
print(traceback.format_exc())
emit('close')
print("END OPENROUTER INFERENCE")
def handle_done(task):
try: try:
task.result() llm = get_llama_cpp_model(app, model)
except asyncio.CancelledError:
print("[openrouter] inference cancelled") kwargs = {}
except Exception as e:
print(f"[openrouter] Worker error: {e}") temp = opts.get("temperature")
if temp is not None:
kwargs['temperature'] = temp
repeat = opts.get("repetition_penalty")
if repeat is not None:
kwargs['repeat_penalty'] = repeat
# Format tools for llama-cpp (OpenAI-compatible function format)
formatted_tools = format_tools(tools) if tools else None
if formatted_tools:
kwargs['tools'] = formatted_tools
kwargs['tool_choice'] = 'auto'
# With stream=True, returns an iterator that infers tokens on demand (openai compatible)
iterator = await loop.run_in_executor(None, lambda: llm.create_chat_completion(
messages=[msg.to_dict() for msg in messages],
stream=True,
**kwargs
))
sentinel = object()
while True:
chunk = await loop.run_in_executor(None, next, iterator, sentinel)
if chunk is sentinel:
break
delta = chunk['choices'][0]['delta']
if 'content' in delta:
if stream is None:
stream = Stream(app)
emit(Message('assistant', stream))
stream.feed(delta['content'])
if 'tool_calls' in delta:
for tc in delta['tool_calls']:
emit(Message('tool', tc))
except Exception:
print(traceback.format_exc()) print(traceback.format_exc())
finally:
if stream: stream.close()
emit(None)
task = asyncio.create_task(worker()) spawn(worker())
task.add_done_callback(handle_done)
agent_run.task = task while True:
message = await queue.get()
if message is None:
break
yield message
async def mock_completion(app, agent_run=None): async def openrouter_chat_completion(app, messages, model, tools=[], opts={}):
if not app.openrouter_client:
raise Exception("OPENROUTER_API_KEY environment variable is not set")
queue = asyncio.Queue()
async def worker():
content_stream = None
reasoning_stream = None
last_channel = None
def open_channel(channel):
nonlocal last_channel, content_stream, reasoning_stream
if channel != last_channel:
if content_stream: content_stream.close()
if reasoning_stream: reasoning_stream.close()
content_stream = None
reasoning_stream = None
last_channel = channel
try:
kwargs = {
'model': model,
'messages': format_openai(messages),
'stream': True,
# Options
'timeout_ms': 45_000,
'retries': 3,
}
# Pass tools in OpenAI-compatible format if provided
formatted_tools = format_tools(tools) if tools else None
if formatted_tools:
kwargs['tools'] = formatted_tools
temp = opts.get("temperature")
if temp is not None:
kwargs['temperature'] = temp
reasoning = opts.get("reasoning_effort")
if reasoning is not None:
kwargs['reasoning'] = {"effort": reasoning}
stream_response = await app.openrouter_client.chat.send_async(**kwargs)
# OpenAI format streams tool calls in parts
# We could, but I won't assume any tool call is complete until the message is over
tool_calls = []
async for event in stream_response:
if event.choices and len(event.choices) > 0:
delta = event.choices[0].delta
if delta.reasoning:
open_channel('reasoning')
if reasoning_stream is None:
reasoning_stream = Stream(app)
queue.put_nowait(Message('reasoning', reasoning_stream))
reasoning_stream.feed(delta.reasoning)
if delta.content:
open_channel('output')
if content_stream is None:
content_stream = Stream(app)
queue.put_nowait(Message('assistant', content_stream))
content_stream.feed(delta.content)
if delta.tool_calls:
for tc in delta.tool_calls:
if tc.index >= len(tool_calls):
tool_calls.append(ToolCall(tool_name="", arguments="", id=""))
tool_call = tool_calls[tc.index]
if tc.id:
tool_call.id += tc.id
if tc.function:
if tc.function.name:
tool_call.tool_name += tc.function.name
if tc.function.arguments:
tool_call.arguments += tc.function.arguments
else:
print("[openrouter] no event choices: " + repr(event))
if content_stream: content_stream.close()
if reasoning_stream: reasoning_stream.close()
for tc in tool_calls:
try:
tc.arguments = json.loads(tc.arguments)
queue.put_nowait(Message('tool', tc))
except Exception:
print("[openrouter] Bad tool call arguments for " + repr(tc))
print(traceback.format_exc())
except Exception:
print(traceback.format_exc())
finally:
queue.put_nowait(None)
spawn(worker())
while True:
message = await queue.get()
if message is None:
break
yield message
async def mock_completion(app):
VERBS = ['fix','scan','sync','deploy','parse','render','cache','trace', VERBS = ['fix','scan','sync','deploy','parse','render','cache','trace',
'merge','purge','index','probe','flush','bind','warm','drain'] 'merge','purge','index','probe','flush','bind','warm','drain']
OBJECTS = ['nodes','packets','tokens','frames','shards','clusters', OBJECTS = ['nodes','packets','tokens','frames','shards','clusters',
@@ -406,20 +385,28 @@ async def mock_completion(app, agent_run=None):
'handles','slots','cursors','segments'] 'handles','slots','cursors','segments']
DELAY = 0.3 DELAY = 0.3
content_stream = Stream(app) queue = asyncio.Queue()
if agent_run: emit = safe_queue(queue.put_nowait)
agent_run.emit_event('output', content_stream)
async def content_task (): async def worker():
await asyncio.sleep(DELAY) stream = Stream(app)
content_stream.feed(random.choice(VERBS)) emit(Message('assistant', stream))
await asyncio.sleep(DELAY) try:
content_stream.feed(f" {random.randint(1, 99)} ") await asyncio.sleep(DELAY)
await asyncio.sleep(DELAY) stream.feed(random.choice(VERBS))
content_stream.feed(random.choice(VERBS)) await asyncio.sleep(DELAY)
await asyncio.sleep(DELAY) stream.feed(f" {random.randint(1, 99)} ")
content_stream.close() await asyncio.sleep(DELAY)
agent_run.emit_event('inference-end', {}) stream.feed(random.choice(VERBS))
await asyncio.sleep(DELAY)
finally:
stream.close()
emit(None)
asyncio.create_task(content_task()) spawn(worker())
if not agent_run: return content_stream
while True:
message = await queue.get()
if message is None:
break
yield message
+8 -3
View File
@@ -1,4 +1,4 @@
import json, asyncio import json, asyncio, traceback
from quart import websocket from quart import websocket
from utils import encode_json from utils import encode_json
from stream import Stream # isinstance check in send-agent-prompt from stream import Stream # isinstance check in send-agent-prompt
@@ -40,10 +40,14 @@ def register(server, app, auth_manager):
send_queue = asyncio.Queue() send_queue = asyncio.Queue()
async def sender_impl(): async def sender_impl():
# This thread MUST NOT crash
nonlocal send_queue nonlocal send_queue
while True: while True:
msg = await send_queue.get() try:
await websocket.send(encode_json(msg)) msg = await send_queue.get()
await websocket.send(encode_json(msg))
except Exception as err:
print(traceback.format_exc())
async def send(msg): async def send(msg):
await send_queue.put(msg) await send_queue.put(msg)
@@ -72,6 +76,7 @@ def register(server, app, auth_manager):
action = req['action'] action = req['action']
body = req.get('body', {}) body = req.get('body', {})
nonce = req.get('nonce', None) nonce = req.get('nonce', None)
print(f"[ws] action @{user_id} {action}")
async def agent_id_from_body (allow_none=False): async def agent_id_from_body (allow_none=False):
if 'agentId' in body: if 'agentId' in body:
+1 -1
View File
@@ -9,7 +9,7 @@ def encode_value(value):
if isinstance(value, ToolSpec): if isinstance(value, ToolSpec):
return value.to_openai_dict() return value.to_openai_dict()
elif isinstance(value, Message): elif isinstance(value, Message):
return {'type': value.type, 'content': encode_value(value.content)} return {'role': value.role, 'content': encode_value(value.content)}
elif isinstance(value, ToolCall): elif isinstance(value, ToolCall):
json = { json = {
'id': value.id, 'id': value.id,