Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion amplifier_web/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
"""Amplifier's local web host."""
__version__ = "0.20.32"
__version__ = "0.20.33"
4 changes: 4 additions & 0 deletions amplifier_web/automatic_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ def display_message(row, index, session, *, include_internal=False):
input_identity = {'nativeInputId': provenance['id']}
if isinstance(provenance,dict) and provenance.get('version')==1 and provenance.get('kind')=='service' and all(isinstance(provenance.get(key),str) and 0<len(provenance[key])<=128 for key in ('id','source')):
observation={'observation':{key:provenance[key] for key in ('id','source','call_id') if key in provenance}}
from .recovery_observations import recovery_facts
facts = recovery_facts(metadata, text, provenance)
if facts:
observation['observation']['recovery'] = facts
return {'id': display_identity(session, index, row['role'], text), 'role': row['role'],
'text': text, 'via': 'chat', 'source': 'native', 'nativeIndex': index,
'createdAt': message_time(row) or session.get('createdAt', 0), 'timestampKnown': message_time(row) is not None,
Expand Down
13 changes: 12 additions & 1 deletion amplifier_web/browser_detail.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def work_segments(session):
for _,index in timed:
position=max(position,index);latest.append(position)
turns={row['id']:row for row in session.get('execution',{}).get('turns',[])}
groups={};anchors={}
groups={};anchors={};last_groups={}
for node in session.get('execution',{}).get('nodes',[]):
turn=turns.get(node.get('turnId'),{})
anchor=turn.get('anchorMessageId')
Expand All @@ -57,6 +57,9 @@ def work_segments(session):
identity=str(node.get('turnId'))+'@'+(anchor or 'start')
group=groups.setdefault(identity,{'id':identity,'anchorMessageId':anchor,'turnId':node.get('turnId'),'members':[]})
group['members'].append(node)
order=(at if isinstance(at,(int,float)) else float('-inf'),len(anchors))
if node.get('turnId') not in last_groups or order >= last_groups[node.get('turnId')][0]:
last_groups[node.get('turnId')]=(order,identity)
result=[]
for group in groups.values():
nodes=group.pop('members');turn=turns.get(group['turnId'],{})
Expand All @@ -66,6 +69,14 @@ def work_segments(session):
running=running or (not starts and not ends and turn.get('phase') in LIVE_PHASES and not turn.get('endedAt'))
calls=[row for row in nodes if row.get('kind')=='llm']
failure=next((row.get('status') or row.get('phase') for row in nodes if (row.get('status') or row.get('phase')) in {'error','failed','cancelled','interrupted'}),None)
# A manager can fail after its final model call succeeded. Attribute
# that outcome to the last segment without rewriting earlier work or
# the successful child call. Use the full tree, including paged nodes.
turn_failure=turn.get('status') or turn.get('phase')
if last_groups[group['turnId']][1]==group['id'] and turn_failure in {'error','failed','cancelled','interrupted'}:
failure=turn_failure
running=False
if isinstance(turn.get('endedAt'),(int,float)):ends.append(turn['endedAt'])
group.update(startedAt=min(starts) if starts else turn.get('startedAt'),
endedAt=None if running else max(ends) if ends else turn.get('endedAt'),phase='running' if running else failure or ('completed' if ends or turn.get('endedAt') else 'recorded'),
nodeCounts={'tools':sum(row.get('kind')=='tool' for row in nodes),'models':sum(row.get('kind')=='llm' for row in nodes)},
Expand Down
45 changes: 45 additions & 0 deletions amplifier_web/recovery_observations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Bounded display facts from host-owned recovery notices, never user text."""
import json
import re


def recovery_facts(metadata, text, provenance):
if provenance.get('source') != 'local-job-recovery':
return None
value = metadata.get('recovery')
if not isinstance(value, dict) or value.get('version') != 1:
# Older loop-live persisted these facts inside its service observation.
# The caller has already established host provenance from metadata.
if len(text) > 16384:
return None
try:
envelope = json.loads(text.split('\n', 1)[1])['observation']
if envelope['source'] != 'local-job-recovery':
return None
value = json.loads(envelope['text'])
except (ValueError, TypeError, KeyError, IndexError, RecursionError):
return None
if not isinstance(value, dict):
return None
job_id = value.get('job_id')
identity = metadata.get('live_recovery_job') or provenance.get('id')
if job_id != identity:
return None
if metadata.get('live_recovery_job') and provenance.get('id') != identity:
return None
result = {}
for key in ('job_id', 'call_id'):
item = value.get(key)
if not isinstance(item, str) or not re.fullmatch(r'[A-Za-z0-9_.:-]{1,200}', item):
return None
result[key] = item
if provenance.get('call_id') and provenance['call_id'] != result['call_id']:
return None
for key, choices in {
'status': {'returned', 'cancelled', 'interrupted', 'failed', 'pending'},
'outcome': {'tool_report_unverified', 'unconfirmed'},
'reason': {'restored_evidence', 'changed_evidence', 'uncertain_outcome'},
}.items():
if isinstance(value.get(key), str) and value[key] in choices:
result[key] = value[key]
return result
41 changes: 31 additions & 10 deletions amplifier_web/release-notes.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,27 @@
{
"schemaVersion": 1,
"releases": [
{
"version": "0.20.33",
"title": "Quieter recovery and clearer conversation failures",
"changes": [
"Keep the chosen model visible through chat startup, failed startup, and reload. Preserve existing ChatGPT sign-in paths when editing a connection.",
"Collapse temporary canvas controls on page reload while retaining pinned controls and saved content.",
"Reconcile completed background work silently when its saved result is already in the conversation. Restore missing evidence without replaying the work, and report uncertain outcomes clearly.",
"Show readable saved-work notices with recorded status, available result and worker links, and optional technical details.",
"Identify when a conversation turn failed and link to its recorded details. Preserve a failed manager outcome even when its final model call succeeded.",
"Handle goals created or revised during an active turn in both Work and Anchors conversations.",
"Keep private startup diagnostic receipts and allow conversations to start when an operational computer-backend readiness failure leaves that tool unavailable."
],
"notices": [
{
"id": "recovery-goal-component-update",
"title": "Finish component updates",
"detail": "The recovery, goal lifecycle and computer startup fixes include updated runtime components. Existing workers retain their loaded code.",
"action": "Let Settings \u2192 Updates finish installing included components, then use a fresh worker."
}
]
},
{
"version": "0.20.32",
"title": "Faster settings saves and shared image tools",
Expand All @@ -16,7 +37,7 @@
"id": "shared-imagegen-component-update",
"title": "Finish component updates",
"detail": "Shared image generation and editing also require the updated Work bundle and the imagegen component. Existing workers retain their loaded code.",
"action": "Let Settings → Updates finish installing components, then use a fresh worker."
"action": "Let Settings \u2192 Updates finish installing components, then use a fresh worker."
}
]
},
Expand All @@ -36,7 +57,7 @@
"id": "charlie-runtime-adoption",
"title": "Finish component updates",
"detail": "Instruction, image-context and native-provider fixes span the application and its Foundation, loop, context and OpenAI provider components. Existing workers retain their loaded code.",
"action": "Let Settings → Updates finish installing components, then use a fresh worker. Explicit source pins and custom forks remain unchanged."
"action": "Let Settings \u2192 Updates finish installing components, then use a fresh worker. Explicit source pins and custom forks remain unchanged."
},
{
"id": "charlie-validation-scope",
Expand Down Expand Up @@ -183,7 +204,7 @@
"title": "Simpler Canvas and clearer provider checks",
"changes": [
"Use one Canvas viewer per browser, removing the split-view controls while keeping saved artifacts and tabs.",
"Copy an opened file’s absolute path or its path relative to the original workspace, clearly labeled as a path on the app server.",
"Copy an opened file\u2019s absolute path or its path relative to the original workspace, clearly labeled as a path on the app server.",
"Explain common provider-check runtime failures, including dependency builds, imports, network trust, DNS, permissions and disk space, without exposing private subprocess logs."
],
"notices": []
Expand Down Expand Up @@ -395,7 +416,7 @@
"id": "update-compaction-components",
"title": "Update companion components for compaction recovery",
"detail": "The context manager and OpenAI provider repairs are separate component updates. They bound oversized summaries, retain native compaction state, and limit repeated attempts after failures.",
"action": "After the app restarts, open Settings → Updates, choose Check now, and install available component updates. Existing source pins remain in effect."
"action": "After the app restarts, open Settings \u2192 Updates, choose Check now, and install available component updates. Existing source pins remain in effect."
}
]
},
Expand Down Expand Up @@ -423,7 +444,7 @@
"id": "refresh-anthropic-tool-routing",
"title": "Update companion modules for Anthropic computer tools",
"detail": "The matching computer-use and loop-streaming fixes are merged in their Microsoft repositories. Existing cached or pinned components are not replaced by the app package alone.",
"action": "After installing this app update, open Settings → Updates, choose Check now, and install the available component updates. Deliberate source pins remain in effect."
"action": "After installing this app update, open Settings \u2192 Updates, choose Check now, and install the available component updates. Deliberate source pins remain in effect."
}
]
},
Expand Down Expand Up @@ -483,7 +504,7 @@
"id": "retry-component-updates",
"title": "Retry a previously failed component update",
"detail": "Install this application update before retrying a component update that failed during preparation.",
"action": "After the app restarts, open Settings → Updates, choose Check now, then Install available."
"action": "After the app restarts, open Settings \u2192 Updates, choose Check now, then Install available."
}
]
},
Expand Down Expand Up @@ -795,8 +816,8 @@
{
"id": "navigation-details-and-drill-in",
"title": "Navigation details and actions moved",
"detail": "The default sidebar now switches between the workspace index and the selected workspace’s chats. Pin, rename and remove actions are in the details flyout; full filesystem paths remain available there and in search. Saved conversations, registrations and custom shell compositions are preserved.",
"action": "Use Workspaces or All workspaces to browse, and hover a row or choose its … button for details and actions."
"detail": "The default sidebar now switches between the workspace index and the selected workspace\u2019s chats. Pin, rename and remove actions are in the details flyout; full filesystem paths remain available there and in search. Saved conversations, registrations and custom shell compositions are preserved.",
"action": "Use Workspaces or All workspaces to browse, and hover a row or choose its \u2026 button for details and actions."
}
]
},
Expand All @@ -816,7 +837,7 @@
"changes": [
"Choose Switch bundle or Fork with this bundle immediately, or use Preview changes first. Both routes are available to app agents too.",
"Switching reuses the configuration it just validated instead of loading the target bundle again. The menu shows progress immediately and prevents duplicate clicks.",
"History, compatible model choices, and rollback remain protected. If your pinned model is unavailable, choose whether to use the new bundle’s model before continuing."
"History, compatible model choices, and rollback remain protected. If your pinned model is unavailable, choose whether to use the new bundle\u2019s model before continuing."
],
"notices": []
},
Expand All @@ -835,7 +856,7 @@
"title": "Work by default and bundle display names",
"changes": [
"New installations and new conversations without a saved bundle choice use Work. Existing conversations and explicit app, workspace, and shared choices are preserved.",
"Bundle pickers and repository discovery show the bundle’s own display_name when available, while saved bundle IDs stay unchanged. Choices remain alphabetically sorted.",
"Bundle pickers and repository discovery show the bundle\u2019s own display_name when available, while saved bundle IDs stay unchanged. Choices remain alphabetically sorted.",
"Display names are read from local or cached manifests and Foundation registry metadata without fetching remote bundles when opening a picker."
],
"notices": [
Expand Down
47 changes: 43 additions & 4 deletions amplifier_web/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,8 @@ async def _drain_stderr(self, row):
while line := await row["process"].stderr.readline():
# Keep diagnostics local and bounded; never stream arbitrary SDK logs
# (which can contain prompts/credentials) into browser state.
row["stderr"].append(line.decode(errors="replace")[-2000:])
from .worker_diagnostics import redact_diagnostic
row["stderr"].append(redact_diagnostic(line.decode(errors="replace"))[-2000:])
row["stderr"] = row["stderr"][-30:]

async def _write(self, row, data):
Expand Down Expand Up @@ -380,6 +381,35 @@ async def _read(self, sid, row):
**{key: data[key] for key in ('status', 'source', 'detail') if key in data}})
elif data.get("type") == "runtime.error":
failure = _worker_error(data)
diagnostic = None
if not row["ready"].done() and not isinstance(failure, SessionInUseError):
from .worker_diagnostics import receipt_path, save_startup_failure
diagnostic = receipt_path(data.get("diagnosticReceipt"))
if diagnostic is None:
# A handled worker error can race the final stderr.
# Wait only briefly; failure must not hang on a worker
# that reports an error without closing its streams.
try:
await asyncio.wait_for(asyncio.shield(row["stderr_task"]), 0.2)
except Exception:
pass
diagnostic = await asyncio.to_thread(save_startup_failure, row, None,
failure=data.get("error") or str(failure))
from .module_failures import ConfiguredModuleError
if not isinstance(failure, ConfiguredModuleError):
from .session_health import failure_details
detail = failure_details(data.get("error"), data.get("errorType"))
labels = {'authentication': 'AuthenticationError', 'rate_limit': 'RateLimitError',
'context_limit': 'ContextLengthError', 'invalid_image': 'InvalidImageError',
'tool_configuration': 'ToolConfigurationError'}
label = labels.get(detail['category'])
# Publish only our bounded classification and guidance,
# never the original provider payload or exception text.
public = (f"{label}: {detail['summary']} {detail['guidance']}" if label
else "The conversation worker could not start.")
failure = RuntimeStartupError(public)
if diagnostic:
failure.diagnostic_path = diagnostic
error = str(failure)
reported_error = error
if not row["ready"].done():
Expand All @@ -389,6 +419,7 @@ async def _read(self, sid, row):
else:
from .module_failures import ConfiguredModuleError
await row["emit"]("runtime.error", {"sessionId": sid, "error": error,
**({"diagnosticReceipt": diagnostic.name} if diagnostic else {}),
**({"moduleFailures": failure.failures} if isinstance(failure, ConfiguredModuleError) else {})})
elif data.get("type") == "history.revised":
await row["emit"]("history.revised", {**data, "sessionId": sid})
Expand All @@ -415,6 +446,7 @@ async def _read(self, sid, row):
await row["emit"](*normalized)
code = await row["process"].wait()
if not row["closing"] and not reported_error:
diagnostic = None
error = f"Amplifier worker exited (code {code}). Work was not replayed."
if not row["ready"].done():
# stdout can reach EOF before the separate stderr reader.
Expand All @@ -426,9 +458,13 @@ async def _read(self, sid, row):
diagnostic = await asyncio.to_thread(save_startup_failure, row, code)
if diagnostic:
error += f" Startup details were saved locally to {diagnostic}."
await row["emit"]("runtime.error", {"sessionId": sid, "error": error})
await row["emit"]("runtime.error", {"sessionId": sid, "error": error,
**({"diagnosticReceipt": diagnostic.name} if diagnostic else {})})
if not row["ready"].done():
row["ready"].set_exception(RuntimeError(error))
failure = RuntimeError(error)
if diagnostic:
failure.diagnostic_path = diagnostic
row["ready"].set_exception(failure)
await self._execution_ended(sid, row, "stopped" if row["closing"] else "interrupted")
except asyncio.CancelledError:
raise
Expand Down Expand Up @@ -556,7 +592,10 @@ async def _start_for_input(self, session, emit):
except SessionInUseError:
raise
except Exception as exc:
raise RuntimeStartupError('The conversation worker could not start. This attempt did not send your message.') from exc
failure = RuntimeStartupError('The conversation worker could not start. This attempt did not send your message.')
if getattr(exc, 'diagnostic_path', None) is not None:
failure.diagnostic_path = exc.diagnostic_path
raise failure from exc

async def delivery(self, session, input_id):
"""Inspect existing evidence; never start a worker or submit an input."""
Expand Down
Loading
Loading