Skip to content
This repository was archived by the owner on Sep 3, 2026. It is now read-only.

Commit 2301a00

Browse files
author
outlndrr
committed
fix(agent): stabilize async runs
Start agent runs in the background so slow providers do not keep POST /agent-runs open. Surface run failures in the UI, update live run status from SSE, and extend provider/runtime timeouts for slow OpenRouter models.
1 parent 3b80b41 commit 2301a00

15 files changed

Lines changed: 624 additions & 143 deletions

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ LUMMY_DEFAULT_MODEL_SELECTION=basic-local
1616
# LUMMY_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
1717
# LUMMY_OPENROUTER_SITE_URL=https://your-app.example.com
1818
# LUMMY_OPENROUTER_APP_NAME=lummy_agent
19+
# LUMMY_OPENROUTER_TIMEOUT_MS=300000
20+
# LUMMY_AGENT_MAX_DURATION_MS=600000
1921

2022
# OpenAI-compatible example
2123
# LUMMY_DEFAULT_PROVIDER_ID=openai-compatible

src/ffi/lummy_agent_provider_http_client_ffi.erl

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,33 +48,46 @@ stream_json_sse_cancellable(Url, Headers, Body, Timeout, CancelSubject, OnData)
4848
RequestOptions = [{sync, false}, {stream, self}, {body_format, binary}],
4949
case httpc:request(post, Request, HttpOptions, RequestOptions) of
5050
{ok, RequestId} ->
51-
await_stream(RequestId, Timeout, CancelSubject, OnData, <<>>);
51+
Deadline = deadline(Timeout),
52+
await_stream(RequestId, Deadline, CancelSubject, OnData, <<>>);
5253
{error, Reason} ->
5354
{error, reason_to_binary(Reason)}
5455
end.
5556

56-
await_stream(RequestId, Timeout, CancelSubject = {subject, _Owner, Tag}, OnData, Buffer) ->
57+
await_stream(RequestId, Deadline, CancelSubject = {subject, _Owner, Tag}, OnData, Buffer) ->
5758
receive
5859
{Tag, _} ->
5960
_ = httpc:cancel_request(RequestId),
6061
{error, <<"cancelled">>};
6162
{http, {RequestId, stream_start, _Headers}} ->
62-
await_stream(RequestId, Timeout, CancelSubject, OnData, Buffer);
63+
await_stream(RequestId, Deadline, CancelSubject, OnData, Buffer);
6364
{http, {RequestId, stream, Chunk}} ->
6465
NextBuffer = emit_stream_events(<<Buffer/binary, Chunk/binary>>, OnData),
65-
await_stream(RequestId, Timeout, CancelSubject, OnData, NextBuffer);
66+
await_stream(RequestId, Deadline, CancelSubject, OnData, NextBuffer);
6667
{http, {RequestId, stream_end, _Headers}} ->
6768
_ = emit_stream_events(Buffer, OnData),
6869
{ok, {200, <<>>}};
6970
{http, {RequestId, {{_Version, Status, _Reason}, _Headers, ResponseBody}}} ->
7071
{ok, {Status, unicode:characters_to_binary(ResponseBody)}};
7172
{http, {RequestId, {error, Reason}}} ->
7273
{error, reason_to_binary(Reason)}
73-
after Timeout ->
74+
after remaining_timeout(Deadline) ->
7475
_ = httpc:cancel_request(RequestId),
7576
{error, <<"timeout">>}
7677
end.
7778

79+
deadline(Timeout) when Timeout =< 0 ->
80+
erlang:monotonic_time(millisecond);
81+
deadline(Timeout) ->
82+
erlang:monotonic_time(millisecond) + Timeout.
83+
84+
remaining_timeout(Deadline) ->
85+
Remaining = Deadline - erlang:monotonic_time(millisecond),
86+
case Remaining > 0 of
87+
true -> Remaining;
88+
false -> 0
89+
end.
90+
7891
emit_stream_events(Buffer, OnData) ->
7992
Normalized = binary:replace(Buffer, <<"\r\n">>, <<"\n">>, [global]),
8093
case binary:match(Normalized, <<"\n\n">>) of

src/lummy_agent/agent/basic_agent.gleam

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ type ToolInvocationMessage {
2929
ToolInvocationExited(process.Down)
3030
}
3131

32+
type BackgroundRunMessage {
33+
BackgroundRunFinished(
34+
Result(session_actor.Snapshot, domain_error.DomainError),
35+
)
36+
BackgroundRunExited(process.Down)
37+
}
38+
3239
pub fn execute(
3340
config: app_config.Config,
3441
_repositories: repository.Repositories,
@@ -42,6 +49,122 @@ pub fn execute(
4249

4350
use _ <- result.try(session_manager.start_run(manager, session_id, run))
4451

52+
execute_started_run(
53+
config,
54+
manager,
55+
session_id,
56+
run,
57+
tools,
58+
initial_usage,
59+
started_ms,
60+
)
61+
}
62+
63+
pub fn start_background(
64+
config: app_config.Config,
65+
manager: session_manager.Handle,
66+
session_id: domain_id.SessionId,
67+
run: domain_run.AgentRun,
68+
) -> Result(session_actor.Snapshot, domain_error.DomainError) {
69+
use tools <- result.try(runtime_builder.for_run(config, run))
70+
let assert Ok(initial_usage) = domain_run.usage(0, 0, 0, 0)
71+
let started_ms = clock.monotonic_ms()
72+
73+
use snapshot <- result.try(session_manager.start_run(manager, session_id, run))
74+
75+
spawn_background_run_worker(
76+
config,
77+
manager,
78+
session_id,
79+
run,
80+
tools,
81+
initial_usage,
82+
started_ms,
83+
)
84+
85+
Ok(snapshot)
86+
}
87+
88+
fn spawn_background_run_worker(
89+
config: app_config.Config,
90+
manager: session_manager.Handle,
91+
session_id: domain_id.SessionId,
92+
run: domain_run.AgentRun,
93+
tools: tool_runtime.Runtime,
94+
initial_usage: domain_run.Usage,
95+
started_ms: Int,
96+
) -> Nil {
97+
let _ =
98+
process.spawn_unlinked(fn() {
99+
let result_subject = process.new_subject()
100+
let worker =
101+
process.spawn_unlinked(fn() {
102+
process.send(
103+
result_subject,
104+
execute_started_run(
105+
config,
106+
manager,
107+
session_id,
108+
run,
109+
tools,
110+
initial_usage,
111+
started_ms,
112+
),
113+
)
114+
})
115+
let monitor = process.monitor(worker)
116+
let selector =
117+
process.new_selector()
118+
|> process.select_map(result_subject, BackgroundRunFinished)
119+
|> process.select_specific_monitor(monitor, BackgroundRunExited)
120+
121+
case process.selector_receive_forever(selector) {
122+
BackgroundRunFinished(Ok(_)) -> process.demonitor_process(monitor)
123+
124+
BackgroundRunFinished(Error(error)) -> {
125+
process.demonitor_process(monitor)
126+
let _ = fail_started_run(manager, session_id, run.run_id, error)
127+
Nil
128+
}
129+
130+
BackgroundRunExited(down) -> {
131+
case process.receive(result_subject, within: 0) {
132+
Ok(Ok(_)) -> Nil
133+
Ok(Error(error)) -> {
134+
let _ = fail_started_run(manager, session_id, run.run_id, error)
135+
Nil
136+
}
137+
Error(_) -> {
138+
let _ =
139+
fail_started_run(
140+
manager,
141+
session_id,
142+
run.run_id,
143+
domain_error.internal(
144+
"basic_agent_worker_crashed",
145+
"basic agent worker exited before finishing: "
146+
<> string.inspect(down),
147+
),
148+
)
149+
Nil
150+
}
151+
}
152+
}
153+
}
154+
})
155+
156+
Nil
157+
}
158+
159+
fn execute_started_run(
160+
config: app_config.Config,
161+
manager: session_manager.Handle,
162+
session_id: domain_id.SessionId,
163+
run: domain_run.AgentRun,
164+
tools: tool_runtime.Runtime,
165+
initial_usage: domain_run.Usage,
166+
started_ms: Int,
167+
) -> Result(session_actor.Snapshot, domain_error.DomainError) {
45168
execute_step(
46169
config,
47170
manager,

src/lummy_agent/app/config.gleam

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ pub fn defaults() -> Config {
108108
openrouter_base_url: "https://openrouter.ai/api/v1",
109109
openrouter_site_url: "http://localhost",
110110
openrouter_app_name: "lummy_agent",
111-
openrouter_timeout_ms: 30_000,
111+
openrouter_timeout_ms: 300_000,
112112
openai_compatible_api_key: "",
113113
openai_compatible_base_url: "https://api.openai.com/v1",
114114
openai_compatible_timeout_ms: 30_000,
@@ -122,7 +122,7 @@ pub fn defaults() -> Config {
122122
max_steps: 4,
123123
max_total_tokens: 16_000,
124124
max_cost_micros: 5_000_000,
125-
max_duration_ms: 60_000,
125+
max_duration_ms: 600_000,
126126
),
127127
tools: ToolsConfig(
128128
timeout_ms: 5000,

src/lummy_agent/session/session_actor.gleam

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,11 +175,11 @@ type CollectorMessage {
175175
CollectorDone(Result(provider.CompletionResponse, domain_error.DomainError))
176176
}
177177

178-
const default_actor_timeout_ms = 5000
178+
const default_actor_timeout_ms = 600_000
179179

180180
const model_adjacent_actor_timeout_ms = 30_000
181181

182-
const model_execution_timeout_ms = 60_000
182+
const model_execution_timeout_ms = 600_000
183183

184184
const chunk_flush_count_threshold = 8
185185

src/lummy_agent/session/session_manager.gleam

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,11 +203,11 @@ type State {
203203
)
204204
}
205205

206-
const default_actor_timeout_ms = 5000
206+
const default_actor_timeout_ms = 600_000
207207

208208
const model_adjacent_actor_timeout_ms = 30_000
209209

210-
const model_execution_timeout_ms = 60_000
210+
const model_execution_timeout_ms = 600_000
211211

212212
pub fn start(config: Config, name: Name) -> actor.StartResult(Handle) {
213213
actor.new_with_initialiser(1000, fn(subject) {

src/lummy_agent/transport/http_server.gleam

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -339,13 +339,7 @@ pub fn handle(
339339
handle_start_run(config, request, manager, session_id)
340340

341341
http.Post, ["api", "v1", "sessions", session_id, "agent-runs"] ->
342-
handle_start_basic_agent_run(
343-
config,
344-
request,
345-
dependencies.repositories,
346-
manager,
347-
session_id,
348-
)
342+
handle_start_basic_agent_run(config, request, manager, session_id)
349343

350344
http.Get, ["api", "v1", "runs", run_id, "event-log"] ->
351345
handle_get_run_event_log(request, dependencies.repositories, run_id)
@@ -1055,7 +1049,6 @@ fn handle_start_run(
10551049
fn handle_start_basic_agent_run(
10561050
config: app_config.Config,
10571051
request: wisp.Request,
1058-
repositories: repository.Repositories,
10591052
manager: session_manager.Handle,
10601053
raw_session_id: String,
10611054
) -> wisp.Response {
@@ -1074,13 +1067,7 @@ fn handle_start_basic_agent_run(
10741067

10751068
Ok(run) ->
10761069
case
1077-
basic_agent.execute(
1078-
config,
1079-
repositories,
1080-
manager,
1081-
session_id,
1082-
run,
1083-
)
1070+
basic_agent.start_background(config, manager, session_id, run)
10841071
{
10851072
Ok(snapshot) ->
10861073
json_response(api_json.snapshot_json(snapshot), 202)

0 commit comments

Comments
 (0)