hum-cli 0.2.63__py3-none-any.whl → 0.2.65__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hum/__init__.py CHANGED
@@ -1,2 +1,2 @@
1
1
  """Hum — an agent harness built as an RL environment that humans can drive."""
2
- __version__ = "0.2.63"
2
+ __version__ = "0.2.65"
hum/client.py CHANGED
@@ -183,11 +183,14 @@ class VisibleTools(ToolRegistry):
183
183
  return results
184
184
 
185
185
 
186
- def retry_line(kind: str, model: str | None, wait_s: float, attempt: int, since_s: float) -> str:
187
- """``model capacity (google/gemini-3.8-flash): waiting 30 s — retry 2 · 1m 05s so far``"""
188
- m, s = divmod(int(since_s), 60)
189
- return (f"model {kind}{f' ({model})' if model else ''}: waiting {wait_s:.0f} s — retry {attempt} · "
190
- f"{f'{m}m {s:02d}s' if m else f'{s}s'} so far")
186
+ def retry_line(kind: str, wait_s: float, attempt: int, of: int) -> str:
187
+ """``Retrying in 30s · attempt 2/10 · capacity``, as Claude Code says it."""
188
+ return f"Retrying in {wait_s:.0f}s · attempt {attempt}/{of} · {kind}"
189
+
190
+
191
+ def waiting_line(s: float) -> str:
192
+ """An attempt that has shown nothing for ``s`` seconds."""
193
+ return f"still waiting for the model ({s:.0f}s)"
191
194
 
192
195
 
193
196
  class VisiblePolicy(PolicyDriver):
@@ -198,14 +201,13 @@ class VisiblePolicy(PolicyDriver):
198
201
  **({"retries": retries} if retries is not None else {}))
199
202
  self.ui = ui
200
203
  self.on_retry = self._retrying
204
+ self.on_waiting = lambda s: ui.set_phase(waiting_line(s))
201
205
  if hasattr(llm, "on_notice"):
202
206
  llm.on_notice = ui.warn # our proxy's word on the person's credits, once
203
207
 
204
208
  def _retrying(self, attempt: int, e: BaseException, delay: float) -> None:
205
- """The person sees the failure's kind, the model it came from and the wait before the next attempt."""
206
- since = self.clock() - (self.started_at or self.clock())
207
- self.ui.retry(attempt, (k := failure_of(e).kind), delay, retry_line(k, getattr(self.llm, "model", None),
208
- delay, attempt, since))
209
+ """The person sees the wait before the next attempt, which attempt of how many, and the failure's kind."""
210
+ self.ui.retry(attempt, (k := failure_of(e).kind), delay, retry_line(k, delay, attempt, self.retries))
209
211
 
210
212
  async def next(self, messages, tools): # type: ignore[override]
211
213
  self.ui.turn_started()
@@ -550,6 +552,7 @@ class Client:
550
552
  return MCPToolset(m.name, url=m.url, command=m.command, args=m.args, headers=server_headers(m),
551
553
  env_pass=self.cfg.sandbox_env_pass, timeout_s=m.timeout_s,
552
554
  connect_timeout_s=m.connect_timeout_s, background_s=m.background_s,
555
+ task_timeout_s=m.task_timeout_s,
553
556
  env_extra=self._env_for(m),
554
557
  meta=self.call_meta if source == "deployment" and not by_identity else None,
555
558
  # a server chosen for this session by its author (an agent file's, a program's) is given
hum/core.py CHANGED
@@ -52,6 +52,10 @@ Front-end → core (commands)
52
52
  {"cmd": "attach", "path": "…"} an image file the person named → image {…}
53
53
  {"cmd": "shell", "command": "…"} the person runs a command themselves (!cmd)
54
54
  {"cmd": "interrupt"} stop the current turn; what it had so far is kept
55
+ {"cmd": "background", "cancel"?: "<call id>"} → info: the calls still out past their turns (a tool server's task,
56
+ a call past background_s); with cancel, that one is stopped (a task is
57
+ cancelled on its server) and the model is woken with its end. The
58
+ one path that cancels a task: a watch's own bound never does
55
59
  {"cmd": "model", "name": "…", "default"?: true} switch model for the rest of the session; default = also the
56
60
  default for new sessions; no name → models {items, via}, the picker
57
61
  {"cmd": "decide", "decision": "accept"|"reject", "note": "…", "remember"?: "session"|"project"} answer a proposal;
@@ -673,7 +677,7 @@ class WireUI(UI):
673
677
  self._text = []
674
678
  self._announced.clear()
675
679
  self._fold_thought()
676
- self.set_phase(f"model {kind}: retry {attempt} in {wait_s:.0f} s")
680
+ self.set_phase(text)
677
681
  self.wire.send("retry", attempt=attempt, kind=kind, wait_s=wait_s, text=text)
678
682
 
679
683
  def error(self, msg: str) -> None:
@@ -1279,6 +1283,16 @@ class Core:
1279
1283
  content = note or ""
1280
1284
  extra: dict[str, Any] = {"event": "task_note", **keep}
1281
1285
  self.ui.info(f"{name}: {' '.join((note or '').split())[:200]}")
1286
+ elif about.get("unwatched"):
1287
+ # the call did not end: Hum let go of a task that still stands on its server. The record keeps it out
1288
+ # (no task_done), so a session opened again watches it again
1289
+ took = duration(float(about.get("elapsed_s") or 0.0))
1290
+ content = (f"[{name} is no longer watched after {took}; its task still stands on its server. Nothing "
1291
+ f"here is from the person.]\n{result.content}")
1292
+ extra = {"event": "task_unwatched", **keep, "elapsed_s": about.get("elapsed_s"),
1293
+ "result": result.model_dump(mode="json")}
1294
+ self.ui.info(f"{name} no longer watched · {took} · task {about.get('task_id') or '?'} still on "
1295
+ f"{about.get('server') or 'its server'}")
1282
1296
  else:
1283
1297
  how = "finished" if result.ok else "failed"
1284
1298
  took = duration(float(about.get("elapsed_s") or 0.0))
@@ -1627,6 +1641,21 @@ class Core:
1627
1641
  async def _cmd_interrupt(self, cmd: dict[str, Any], w: Writer | None) -> None:
1628
1642
  await self.interrupt()
1629
1643
 
1644
+ async def _cmd_background(self, cmd: dict[str, Any], w: Writer | None) -> None:
1645
+ which = str(cmd.get("cancel") or "").strip()
1646
+ if which:
1647
+ said = self.background.cancel(which, "a person cancelled it")
1648
+ if said == "stopping":
1649
+ self.ui.info(f"stopping {which}: when it has stopped, its end shows here and reaches the model")
1650
+ elif said == "ended":
1651
+ self.ui.info(f"{which} had already ended: its result reaches the model, not a stop")
1652
+ else:
1653
+ self.ui.warn(f"no call {which} is out: /background lists the ones that are")
1654
+ return
1655
+ out = self.background.pending
1656
+ self.ui.info("\n".join(f"{p['call_id']} {p['name']}" + (f" task {p['task_id']}" if p.get("task_id") else "")
1657
+ + f" {p['elapsed_s']:.0f}s" for p in out) if out else "no calls are out")
1658
+
1630
1659
  async def _cmd_model(self, cmd: dict[str, Any], w: Writer | None) -> None:
1631
1660
  name = str(cmd.get("name", "")).strip()
1632
1661
  if name:
@@ -2392,6 +2421,7 @@ class Core:
2392
2421
  "send": _cmd_send,
2393
2422
  "shell": _cmd_shell,
2394
2423
  "interrupt": _cmd_interrupt,
2424
+ "background": _cmd_background,
2395
2425
  "model": _cmd_model,
2396
2426
  "reasoning": _cmd_reasoning,
2397
2427
  "decide": _cmd_decide,
hum/plain.py CHANGED
@@ -38,6 +38,8 @@ HELP = """/help this
38
38
  /goal [text] set the goal the session keeps working toward, exchange after exchange, until the model marks it
39
39
  complete or blocked; /goal pause · resume · clear; /goal alone shows where it stands
40
40
  /agent <n> … speak to one running sub-agent (it reads it at its next step); /agent <n> stop ends it
41
+ /background the tool calls still out past their turns (a server's task, a long call), by call id;
42
+ /background cancel <call id> stops one (a task is cancelled on its server)
41
43
  /diff [what] what has changed since this session began (/diff last: the last exchange; /diff <path>: one file)
42
44
  /rewind [n] the points you can go back to; /rewind <n> [files|chat|both] goes back to one
43
45
  /accept [why] the work as it stands is what you wanted
@@ -525,6 +527,14 @@ class Plain:
525
527
  send(cmd="agent", agent=int(n), stop=True)
526
528
  else:
527
529
  send(cmd="agent", agent=int(n), text=rest.strip())
530
+ elif cmd == "background":
531
+ verb, _, which = arg.partition(" ")
532
+ if not arg:
533
+ send(cmd="background")
534
+ elif verb == "cancel" and which.strip():
535
+ send(cmd="background", cancel=which.strip())
536
+ else:
537
+ self.ui.info("/background lists the calls still out; /background cancel <call id> stops one")
528
538
  elif cmd == "goal":
529
539
  send(cmd="goal", text=arg)
530
540
  elif cmd == "diff":
@@ -32,7 +32,7 @@ from typing import Any
32
32
 
33
33
  import yaml
34
34
 
35
- from .config import MCP_BACKGROUND_S, MCP_CONNECT_TIMEOUT_S, MCP_TIMEOUT_S, MCPConfig
35
+ from .config import MCP_BACKGROUND_S, MCP_CONNECT_TIMEOUT_S, MCP_TASK_TIMEOUT_S, MCP_TIMEOUT_S, MCPConfig
36
36
  from .skills import _FRONT, _NAME_OK, front_matter
37
37
 
38
38
  # Hum's own, and nobody else's: another harness keeps its agents in its own folder, in its own format, and
@@ -174,7 +174,8 @@ def _server_words(m: MCPConfig) -> dict[str, Any]:
174
174
  ("timeout_s", m.timeout_s if m.timeout_s != MCP_TIMEOUT_S else None),
175
175
  ("connect_timeout_s", m.connect_timeout_s
176
176
  if m.connect_timeout_s != MCP_CONNECT_TIMEOUT_S else None),
177
- ("background_s", m.background_s if m.background_s != MCP_BACKGROUND_S else None))
177
+ ("background_s", m.background_s if m.background_s != MCP_BACKGROUND_S else None),
178
+ ("task_timeout_s", m.task_timeout_s if m.task_timeout_s != MCP_TASK_TIMEOUT_S else None))
178
179
  if v or (k == "background_s" and v is not None)}
179
180
 
180
181
 
@@ -191,6 +192,7 @@ def server_config(name: str, spec: Any, root: Path | None = None) -> MCPConfig |
191
192
  command = str((root / str(command)).resolve())
192
193
  headers = spec.get("headers") or {}
193
194
  timeout, connect, background = spec.get("timeout_s"), spec.get("connect_timeout_s"), spec.get("background_s")
195
+ tasks = spec.get("task_timeout_s")
194
196
  return MCPConfig(name=str(name).strip(), url=str(url) if url else None, command=str(command) if command else None,
195
197
  args=[str(a) for a in spec.get("args") or []],
196
198
  headers={str(k): str(v) for k, v in dict(headers).items()},
@@ -198,7 +200,8 @@ def server_config(name: str, spec: Any, root: Path | None = None) -> MCPConfig |
198
200
  env={str(k): str(v) for k, v in dict(spec.get("env") or {}).items()},
199
201
  timeout_s=float(timeout) if timeout else MCP_TIMEOUT_S,
200
202
  connect_timeout_s=float(connect) if connect else MCP_CONNECT_TIMEOUT_S,
201
- background_s=float(background) if background is not None else MCP_BACKGROUND_S)
203
+ background_s=float(background) if background is not None else MCP_BACKGROUND_S,
204
+ task_timeout_s=float(tasks) if tasks else MCP_TASK_TIMEOUT_S)
202
205
 
203
206
 
204
207
  def _front_servers(text: str, root: Path | None) -> list[MCPConfig]:
hum/runtime/background.py CHANGED
@@ -90,33 +90,110 @@ class GetTaskRequest(mcp_types.Request[_TaskParams, Literal["tasks/get"]]):
90
90
  name_param = "taskId" # the extension routes tasks/* by the task id in the Mcp-Name header
91
91
 
92
92
 
93
+ class CancelTaskRequest(mcp_types.Request[_TaskParams, Literal["tasks/cancel"]]):
94
+ method: Literal["tasks/cancel"] = "tasks/cancel"
95
+ params: _TaskParams
96
+ name_param = "taskId"
97
+
98
+
93
99
  _TASK = TypeAdapter(Task)
100
+ _ANY = TypeAdapter(dict[str, Any])
101
+ POLL_TIMEOUT_S = 30.0 # one tasks/get: a poll the server does not answer in this long is asked again, not waited on
94
102
 
95
103
 
96
- async def get_task(session: Any, task_id: str) -> Task:
104
+ async def get_task(session: Any, task_id: str, timeout_s: float | None = POLL_TIMEOUT_S) -> Task:
97
105
  """One poll: the task as the server has it now. Raises the SDK's MCPError for a task the server no longer
98
- holds (its TTL ran out) or never had."""
99
- return await session.send_request(GetTaskRequest(params=_TaskParams(task_id=task_id)), _TASK)
100
-
101
-
102
- async def poll(session: Any, task: Task, *, on_status: Callable[[Task], Any] | None = None,
103
- now: bool = False) -> Task:
104
- """Follow one task to its end: sleep the interval the server names, ask again, until a terminal status.
105
- ``now`` asks before the first sleep (a task known only by its id, from the record). ``on_status`` hears
106
- every change of status on the way (working to input_required, and back). A task whose TTL runs out before
107
- it ends comes back as it last stood, with ``status`` ``failed`` and the reason in its message, since the
108
- server may have dropped it."""
109
- seen = time.monotonic()
106
+ holds (its TTL ran out) or never had, and for a poll it did not answer within ``timeout_s``."""
107
+ return await session.send_request(GetTaskRequest(params=_TaskParams(task_id=task_id)), _TASK,
108
+ request_read_timeout_seconds=timeout_s)
109
+
110
+
111
+ async def cancel_task(session: Any, task_id: str, timeout_s: float | None = POLL_TIMEOUT_S) -> None:
112
+ """Ask the server to cancel a task. Cancellation is eventually consistent on its side; the ack is all this
113
+ waits for."""
114
+ await session.send_request(CancelTaskRequest(params=_TaskParams(task_id=task_id)), _ANY,
115
+ request_read_timeout_seconds=timeout_s)
116
+
117
+
118
+ class CallCancelled(Exception):
119
+ """A call still out that a person stopped (Background.cancel), in the words the model reads."""
120
+
121
+
122
+ class TaskUnwatched(Exception):
123
+ """A watch that ended with its task still standing on its server: Hum stopped following it (its ceiling
124
+ passed, or the server could not be reached), it did not end. The words are what the model reads; the record
125
+ keeps the call out, so a session opened again watches the task again."""
126
+
127
+
128
+ class TaskTimedOut(Exception):
129
+ """A task Hum followed past its client's ceiling (``task_timeout_s``), the task as it last stood."""
130
+
131
+ def __init__(self, task: Task, ceiling_s: float):
132
+ super().__init__(f"task {task.task_id} was still {task.status} after {ceiling_s:.0f}s")
133
+ self.task, self.ceiling_s = task, ceiling_s
134
+
135
+
136
+ class TaskUnreachable(Exception):
137
+ """Polls of a task that reached no server, as many in a row as the watch allows: the task's state is
138
+ unknown, not ended."""
139
+
140
+ def __init__(self, task_id: str, tries: int, cause: BaseException):
141
+ super().__init__(f"task {task_id}: {tries} polls in a row reached no server ({cause})")
142
+ self.task_id, self.tries, self.cause = task_id, tries, cause
143
+
144
+
145
+ def created_at(task: Task) -> float | None:
146
+ """When the server says the task was made, as wall-clock seconds; None when it does not say legibly. A
147
+ timestamp with no offset is read as UTC, as the extension's own are written."""
148
+ if not task.created_at:
149
+ return None
150
+ from datetime import datetime, timezone
151
+ try:
152
+ when = datetime.fromisoformat(task.created_at.replace("Z", "+00:00"))
153
+ except ValueError:
154
+ return None
155
+ if when.tzinfo is None:
156
+ when = when.replace(tzinfo=timezone.utc)
157
+ return when.timestamp()
158
+
159
+
160
+ async def poll(fetch: Callable[[str], Awaitable[Task]], task: Task, *,
161
+ on_status: Callable[[Task], Any] | None = None, now: bool = False,
162
+ ceiling_s: float | None = None) -> Task:
163
+ """Follow one task to its end: sleep the interval the server names, ``fetch`` it again, until a terminal
164
+ status. ``now`` asks before the first sleep (a task known only by its id, from the record). ``on_status``
165
+ hears every change of status on the way (working to input_required, and back).
166
+
167
+ The task's ``ttlMs`` is how long the server may keep it, not a deadline for the client: a task that outlives
168
+ it is followed while the server still answers for it, and one the server has dropped ends the watch with the
169
+ server's own error. The client's own bound is ``ceiling_s``: how long this watch follows the task, counted
170
+ from when it began (a session opened again watches for a fresh while). Past it, a sleep or a fetch still out
171
+ included, the watch raises TaskTimedOut with the task as it last stood. It only ends the watch: the task
172
+ stands on its server."""
173
+ began = time.monotonic()
174
+
175
+ def left() -> float:
176
+ return began + ceiling_s - time.monotonic() if ceiling_s is not None else float("inf")
177
+
110
178
  while not task.done:
111
- if task.ttl_ms and time.monotonic() - seen > task.ttl_ms / 1000.0 + task.interval_s():
112
- return task.model_copy(update={"status": "failed",
113
- "status_message": f"the task's time to live ({task.ttl_ms} ms) ran out "
114
- f"with it still {task.status}"})
179
+ if left() <= 0:
180
+ raise TaskTimedOut(task, ceiling_s or 0.0)
115
181
  if now:
116
182
  now = False
117
183
  else:
118
- await asyncio.sleep(task.interval_s())
119
- fresh = await get_task(session, task.task_id)
184
+ await asyncio.sleep(max(0.0, min(task.interval_s(), left())))
185
+ if left() <= 0:
186
+ raise TaskTimedOut(task, ceiling_s or 0.0)
187
+ if ceiling_s is None:
188
+ fresh = await fetch(task.task_id)
189
+ else:
190
+ try:
191
+ async with asyncio.timeout(left()) as bound:
192
+ fresh = await fetch(task.task_id)
193
+ except TimeoutError:
194
+ if bound.expired():
195
+ raise TaskTimedOut(task, ceiling_s) from None
196
+ raise
120
197
  if fresh.status != task.status and on_status is not None:
121
198
  on_status(fresh)
122
199
  task = fresh
@@ -132,7 +209,7 @@ def task_extension() -> tuple[dict[str, dict[str, Any]], dict[str, list[Any]]]:
132
209
  from mcp.types import CallToolResult
133
210
 
134
211
  async def resolve(handle: TaskHandle, ctx: ClaimContext) -> CallToolResult:
135
- task = await poll(ctx.session, handle.task())
212
+ task = await poll(lambda tid: get_task(ctx.session, tid), handle.task())
136
213
  return call_result(task)
137
214
 
138
215
  claim = ResultClaim(result_type="task", model=TaskHandle, resolve=resolve)
@@ -203,6 +280,9 @@ class Deferred:
203
280
  outcome: Callable[[Callable[[str], None]], Awaitable[Any]]
204
281
  task_id: str | None = None
205
282
  waited_s: float = 0.0 # how long the turn waited before the call went on without it
283
+ # how to stop the call where it runs, for a person who no longer wants it: a task is cancelled on its server
284
+ # (tasks/cancel), a plain call's request is given up on. None: there is nothing to stop but the watch
285
+ cancel: Callable[[], Awaitable[None]] | None = None
206
286
 
207
287
  def about(self) -> dict[str, Any]:
208
288
  """The facts the record keeps about the call that went on: enough to pick the watch up again."""
@@ -218,6 +298,9 @@ class Watch:
218
298
  deferred: Deferred
219
299
  started: float = field(default_factory=time.monotonic)
220
300
  task: asyncio.Task | None = None
301
+ stopped: str | None = None # why a person stopped this call (Background.cancel); None while it runs
302
+ stop: asyncio.Event = field(default_factory=asyncio.Event)
303
+ outcome: asyncio.Future | None = None # the call's own end, as it runs
221
304
 
222
305
  def facts(self) -> dict[str, Any]:
223
306
  return {"call_id": self.call.id, "name": self.call.name, **self.deferred.about(),
@@ -263,19 +346,66 @@ class Background:
263
346
  if self.deliver is not None:
264
347
  self.deliver(w.facts(), None, text)
265
348
  try:
349
+ outcome = w.outcome = asyncio.ensure_future(w.deferred.outcome(say))
350
+ stop = asyncio.ensure_future(w.stop.wait())
266
351
  try:
267
- raw = await w.deferred.outcome(say)
268
- except asyncio.CancelledError:
269
- raise
270
- except BaseException as e: # the failure goes where the text would have: finish renders it
271
- raw = e
352
+ await asyncio.wait((outcome, stop), return_when=asyncio.FIRST_COMPLETED)
353
+ finally:
354
+ stop.cancel()
355
+ if not outcome.done():
356
+ outcome.cancel()
357
+ with contextlib.suppress(asyncio.CancelledError, Exception):
358
+ await outcome
359
+ if not outcome.cancelled() and outcome.exception() is None:
360
+ raw = outcome.result()
361
+ elif w.stopped is not None:
362
+ raw = CallCancelled(await self._stop_at_source(w))
363
+ elif outcome.cancelled():
364
+ raise asyncio.CancelledError()
365
+ else: # the failure goes where the text would have: finish renders it
366
+ raw = outcome.exception()
272
367
  result = await finish(raw, w.started)
273
368
  except asyncio.CancelledError:
274
369
  self._watches.pop(w.call.id, None)
275
370
  raise
276
371
  self._watches.pop(w.call.id, None)
277
372
  if self.deliver is not None:
278
- self.deliver(w.facts(), result, None)
373
+ # a watch that let go of a task still standing says so: the record keeps the call out for the next
374
+ # opening to watch again, where an ended call is answered
375
+ self.deliver({**w.facts(), **({"unwatched": True} if isinstance(raw, TaskUnwatched) else {})},
376
+ result, None)
377
+
378
+ def cancel(self, call_id: str, why: str = "a person cancelled it") -> Literal["stopping", "ended"] | None:
379
+ """Stop one call still out, by the id of the call that started it: a task is cancelled on its server, a
380
+ plain call is given up on, and the model is woken with the call's end in those words. ``stopping`` when
381
+ the stop goes ahead; ``ended`` when the call had already ended and its result is on its way (it is
382
+ delivered, not stopped); None when no call by that id is out. This is the one path that cancels a task
383
+ on its server: a person's or the model's word, never a watch's own bound."""
384
+ w = self._watches.get(call_id)
385
+ if w is None or w.task is None or w.task.done():
386
+ return None
387
+ if w.stopped is not None:
388
+ return "stopping"
389
+ if w.outcome is not None and w.outcome.done():
390
+ return "ended"
391
+ w.stopped = why
392
+ w.stop.set()
393
+ return "stopping"
394
+
395
+ @staticmethod
396
+ async def _stop_at_source(w: Watch) -> str:
397
+ """What stopping the call did where it runs, in the words the model reads."""
398
+ what = f"{w.call.name}" + (f" (task {w.deferred.task_id})" if w.deferred.task_id else "")
399
+ if w.deferred.cancel is None:
400
+ return f"{what} was stopped: {w.stopped}. Hum no longer waits for it."
401
+ try:
402
+ await w.deferred.cancel()
403
+ except Exception as e:
404
+ return (f"{what} was stopped: {w.stopped}. Hum asked {w.deferred.server} to cancel it and it did not "
405
+ f"say it would ({type(e).__name__}: {e}); it may still run there.")
406
+ if w.deferred.kind == "task":
407
+ return f"{what} was stopped: {w.stopped}. Hum asked {w.deferred.server} to cancel the task."
408
+ return f"{what} was stopped: {w.stopped}. Hum gave up its request; the server may still finish it."
279
409
 
280
410
  async def close(self) -> None:
281
411
  """Stop watching: the tasks stand on their servers, and a session opened again finds them by the ids
hum/runtime/config.py CHANGED
@@ -18,6 +18,11 @@ MCP_TIMEOUT_S = 900.0
18
18
  MCP_CONNECT_TIMEOUT_S = 30.0
19
19
  # how long a call holds the turn before it goes on in the background and its result comes back as a wake
20
20
  MCP_BACKGROUND_S = 120.0
21
+ # how long one watch follows a tool server's task before it lets go: the client's own bound, whatever time to live the
22
+ # server names, so a task that never ends does not hold a watch forever. Letting go never cancels the task: it goes on
23
+ # on its server, the model is told its id, and a session opened again watches it again. Generous: a Harwell run may
24
+ # take an hour, and a task is a call that was always going to be long
25
+ MCP_TASK_TIMEOUT_S = 7200.0
21
26
 
22
27
 
23
28
  @dataclass
@@ -33,6 +38,8 @@ class MCPConfig:
33
38
  # [[mcp]] background_s: seconds a call holds the turn before it goes on in the background; 0 holds the turn
34
39
  # to the deadline
35
40
  background_s: float = MCP_BACKGROUND_S
41
+ # [[mcp]] task_timeout_s: seconds one watch follows one of this server's tasks before it lets go (never cancels it)
42
+ task_timeout_s: float = MCP_TASK_TIMEOUT_S
36
43
  env: dict[str, str] = field(default_factory=dict) # a plugin's server: what its process is given
37
44
  plugin: str = "" # the plugin that brought it, when one did
38
45
  of_repository: bool = False # the repository's word named it, not the person's (trust.claim)
@@ -65,9 +72,11 @@ class Config:
65
72
  api_key: str | None = None
66
73
  reasoning_effort: str | None = None
67
74
  temperature: float | None = None
68
- idle_timeout_s: float = 600.0 # a stream silent this long is dead (models can think silently for minutes)
75
+ # [model] idle_timeout_s: once an attempt is answered, the longest silence between stream events (keepalives count)
76
+ # before it is cut and retried; the gateway keeps a long think alive (hum.runtime.llm.STREAM_IDLE_S)
77
+ idle_timeout_s: float = 300.0
69
78
  # [model] retries: requests tried again after a throttle, an upstream failure or a dropped connection
70
- retries: int = 10 # a cap; the wait budget (hum.runtime.drivers.RETRY_BUDGET_S) is what binds
79
+ retries: int = 10 # the most retries of a turn; fewer by kind and wait (hum.runtime.drivers)
71
80
  mcp: list[MCPConfig] = field(default_factory=list)
72
81
  lsp: list[LSPConfig] = field(default_factory=list) # [[lsp]]: language servers, beside the ones found on PATH
73
82
  gate: str = "off"
@@ -169,6 +178,7 @@ def _server(s: dict[str, Any], of_repository: bool = False) -> MCPConfig:
169
178
  timeout_s=float(s.get("timeout_s") or MCP_TIMEOUT_S),
170
179
  connect_timeout_s=float(s.get("connect_timeout_s") or MCP_CONNECT_TIMEOUT_S),
171
180
  background_s=float(s["background_s"]) if s.get("background_s") is not None else MCP_BACKGROUND_S,
181
+ task_timeout_s=float(s.get("task_timeout_s") or MCP_TASK_TIMEOUT_S),
172
182
  env={str(k): str(v) for k, v in (s.get("env") or {}).items()},
173
183
  of_repository=of_repository)
174
184
 
@@ -293,7 +303,7 @@ def load(path: Path | None = None) -> Config:
293
303
  # server's default
294
304
  reasoning_effort=_env("HUM_REASONING_EFFORT") or m.get("reasoning_effort") or cred.get("reasoning_effort")
295
305
  or None,
296
- temperature=m.get("temperature"), idle_timeout_s=float(m.get("idle_timeout_s", 600.0)),
306
+ temperature=m.get("temperature"), idle_timeout_s=float(m.get("idle_timeout_s", 300.0)),
297
307
  retries=int(m.get("retries", 10)),
298
308
  mcp=[_server(s) for s in d.get("mcp", []) if s.get("name")],
299
309
  lsp=[LSPConfig(name=str(s["name"]), command=str(s["command"]), args=[str(x) for x in s.get("args", [])],