This commit is contained in:
2026-04-17 20:49:21 -04:00
parent 4e243751b2
commit bbddd58f98
6 changed files with 106 additions and 19 deletions

View File

@@ -177,10 +177,9 @@ export class AuthService {
async getSession(token: string) { async getSession(token: string) {
try { try {
const session = await this.config.auth.api.getSession({ const session = await this.config.auth.api.getSession({
headers: { headers: new Headers({
// Better Auth expects the session token in the cookie header 'Authorization': `Bearer ${token}`,
cookie: `better-auth.session_token=${token}`, }),
},
}); });
return session; return session;

View File

@@ -264,7 +264,12 @@ export class AgentHarness {
this.mcpClient, this.mcpClient,
this.availableMCPTools, this.availableMCPTools,
this.workspaceManager, this.workspaceManager,
(img) => this.researchImageCapture.push(img) (img) => this.researchImageCapture.push(img),
(storeName, newState) => {
this.workspaceManager?.setState(storeName, newState).catch((err) =>
this.config.logger.error({ err, storeName }, 'Failed to sync workspace after research mutation')
);
}
); );
// Inject web_explore tool if the web-explore subagent is ready // Inject web_explore tool if the web-explore subagent is ready
@@ -475,7 +480,11 @@ export class AgentHarness {
this.availableMCPTools, this.availableMCPTools,
this.workspaceManager, this.workspaceManager,
undefined, undefined,
undefined (storeName, newState) => {
this.workspaceManager?.setState(storeName, newState).catch((err) =>
this.config.logger.error({ err, storeName }, 'Failed to sync workspace after strategy mutation')
);
}
); );
const strategySubagentPath = join(__dirname, 'subagents', 'strategy'); const strategySubagentPath = join(__dirname, 'subagents', 'strategy');

View File

@@ -45,21 +45,26 @@ export function createMCPToolWrapper(
// Fire workspace mutation callback when workspace_patch or workspace_write succeeds. // Fire workspace mutation callback when workspace_patch or workspace_write succeeds.
// The sandbox returns {"success": true, "data": <newState>} as a text content item. // The sandbox returns {"success": true, "data": <newState>} as a text content item.
if ( if (onWorkspaceMutation) {
onWorkspaceMutation &&
(toolInfo.name === 'workspace_patch' || toolInfo.name === 'workspace_write')
) {
const content = (result as any)?.content; const content = (result as any)?.content;
if (Array.isArray(content)) { if (Array.isArray(content)) {
for (const item of content) { for (const item of content) {
if (item.type === 'text' && item.text) { if (item.type === 'text' && item.text) {
try { try {
const parsed = JSON.parse(item.text); const parsed = JSON.parse(item.text);
if (parsed?.success && parsed?.data !== undefined) { // workspace_patch / workspace_write: {"success": true, "data": <state>}
if (
(toolInfo.name === 'workspace_patch' || toolInfo.name === 'workspace_write') &&
parsed?.success && parsed?.data !== undefined
) {
onWorkspaceMutation((input as any).store_name as string, parsed.data); onWorkspaceMutation((input as any).store_name as string, parsed.data);
} }
// python_write / python_edit / python_delete / python_revert:
// {"_workspace_sync": {"store": <name>, "data": <state>}}
if (parsed?._workspace_sync?.store && parsed._workspace_sync.data !== undefined) {
onWorkspaceMutation(parsed._workspace_sync.store, parsed._workspace_sync.data);
}
} catch { /* ignore parse errors */ } } catch { /* ignore parse errors */ }
break; // only need first text item
} }
} }
} }

View File

@@ -6,10 +6,12 @@ All internal timestamps use nanoseconds since epoch (UTC).
""" """
import logging import logging
import re
from typing import Union from typing import Union
from datetime import datetime from datetime import datetime, timezone
import pandas as pd import pandas as pd
from dateutil import parser as dateparser from dateutil import parser as _dateutil_parser
from dateutil.relativedelta import relativedelta
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -18,6 +20,20 @@ TimestampInput = Union[int, float, str, datetime, pd.Timestamp]
NANOS_PER_SECOND = 1_000_000_000 NANOS_PER_SECOND = 1_000_000_000
_RELATIVE_RE = re.compile(
r'^(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago$',
re.IGNORECASE,
)
def _parse_relative_date(s: str) -> datetime | None:
"""Parse relative date strings like '30 days ago', '2 weeks ago'."""
m = _RELATIVE_RE.match(s.strip())
if not m:
return None
n, unit = int(m.group(1)), m.group(2).lower()
return datetime.now(timezone.utc) - relativedelta(**{f'{unit}s': n})
def to_nanoseconds(timestamp: TimestampInput) -> int: def to_nanoseconds(timestamp: TimestampInput) -> int:
""" """
@@ -31,6 +47,7 @@ def to_nanoseconds(timestamp: TimestampInput) -> int:
timestamp: Can be: timestamp: Can be:
- Unix timestamp (int/float) - assumed to be in seconds - Unix timestamp (int/float) - assumed to be in seconds
- ISO date string (str) - parsed using dateutil - ISO date string (str) - parsed using dateutil
- Relative date string (str) - e.g. "30 days ago", "2 weeks ago"
- datetime object - datetime object
- pandas Timestamp - pandas Timestamp
@@ -48,7 +65,9 @@ def to_nanoseconds(timestamp: TimestampInput) -> int:
if isinstance(timestamp, (int, float)): if isinstance(timestamp, (int, float)):
return int(timestamp * NANOS_PER_SECOND) return int(timestamp * NANOS_PER_SECOND)
elif isinstance(timestamp, str): elif isinstance(timestamp, str):
dt = dateparser.parse(timestamp) dt = _parse_relative_date(timestamp)
if dt is None:
dt = _dateutil_parser.parse(timestamp)
if dt is None: if dt is None:
raise ValueError(f"Could not parse date string: {timestamp}") raise ValueError(f"Could not parse date string: {timestamp}")
return int(dt.timestamp() * NANOS_PER_SECOND) return int(dt.timestamp() * NANOS_PER_SECOND)

View File

@@ -155,6 +155,21 @@ def _remove_indicator_instances(workspace_store, pandas_ta_name: str) -> None:
logging.warning(f"Failed to remove indicator instances for {pandas_ta_name}", exc_info=True) logging.warning(f"Failed to remove indicator instances for {pandas_ta_name}", exc_info=True)
def _workspace_sync_content(workspace_store, category: str) -> "TextContent | None":
"""
Return a TextContent item carrying the current {category}_types workspace state so the
gateway can sync it to connected web clients without a separate workspace_patch call.
The gateway detects items of the form {"_workspace_sync": {"store": ..., "data": ...}}.
"""
store = _type_store_name(category)
result = workspace_store.read(store)
if not result.get('exists'):
return None
import json as _json
payload = _json.dumps({"_workspace_sync": {"store": store, "data": result.get("data")}})
return TextContent(type="text", text=payload)
def _populate_types_from_disk(workspace_store, category_manager, category: str) -> None: def _populate_types_from_disk(workspace_store, category_manager, category: str) -> None:
"""Scan existing category items and add any missing entries to the {category}_types store.""" """Scan existing category items and add any missing entries to the {category}_types store."""
store = _type_store_name(category) store = _type_store_name(category)
@@ -921,6 +936,9 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
if result.get("success"): if result.get("success"):
_upsert_type(workspace_store, category_manager, arguments.get("category", ""), arguments.get("name", "")) _upsert_type(workspace_store, category_manager, arguments.get("category", ""), arguments.get("name", ""))
await cleanup_extra_packages_async(get_data_dir(), _get_env_yml()) await cleanup_extra_packages_async(get_data_dir(), _get_env_yml())
sync = _workspace_sync_content(workspace_store, arguments.get("category", ""))
if sync:
content.append(sync)
return content return content
elif name == "python_edit": elif name == "python_edit":
result = await category_manager.edit( result = await category_manager.edit(
@@ -952,6 +970,9 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
if result.get("success"): if result.get("success"):
_upsert_type(workspace_store, category_manager, arguments.get("category", ""), arguments.get("name", "")) _upsert_type(workspace_store, category_manager, arguments.get("category", ""), arguments.get("name", ""))
await cleanup_extra_packages_async(get_data_dir(), _get_env_yml()) await cleanup_extra_packages_async(get_data_dir(), _get_env_yml())
sync = _workspace_sync_content(workspace_store, arguments.get("category", ""))
if sync:
content.append(sync)
return content return content
elif name == "python_read": elif name == "python_read":
return category_manager.read( return category_manager.read(
@@ -987,6 +1008,11 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
meta_parts.append(f"validation errors: {result['validation'].get('errors', [])}") meta_parts.append(f"validation errors: {result['validation'].get('errors', [])}")
if result.get("success"): if result.get("success"):
_upsert_type(workspace_store, category_manager, arguments.get("category", ""), arguments.get("name", "")) _upsert_type(workspace_store, category_manager, arguments.get("category", ""), arguments.get("name", ""))
sync = _workspace_sync_content(workspace_store, arguments.get("category", ""))
content_out = [TextContent(type="text", text="\n".join(meta_parts))]
if sync:
content_out.append(sync)
return content_out
return [TextContent(type="text", text="\n".join(meta_parts))] return [TextContent(type="text", text="\n".join(meta_parts))]
elif name == "python_delete": elif name == "python_delete":
result = await category_manager.delete( result = await category_manager.delete(
@@ -1002,7 +1028,12 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
for k in ("category", "name", "revision", "packages_removed", "error"): for k in ("category", "name", "revision", "packages_removed", "error"):
if result.get(k): if result.get(k):
parts.append(f"{k}: {result[k]}") parts.append(f"{k}: {result[k]}")
return [TextContent(type="text", text="\n".join(parts))] content_out = [TextContent(type="text", text="\n".join(parts))]
if result.get("success"):
sync = _workspace_sync_content(workspace_store, arguments.get("category", ""))
if sync:
content_out.append(sync)
return content_out
elif name == "conda_sync": elif name == "conda_sync":
return await sync_packages_async( return await sync_packages_async(
data_dir=get_data_dir(), data_dir=get_data_dir(),

View File

@@ -653,10 +653,10 @@ onUnmounted(() => {
v-if="isAgentProcessing" v-if="isAgentProcessing"
slot="send-icon" slot="send-icon"
@click.stop="stopAgent" @click.stop="stopAgent"
style="display:flex;align-items:center;justify-content:center;width:100%;height:100%" class="stop-btn"
> >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18">
<rect x="4" y="4" width="16" height="16" rx="2" fill="#f23645"/> <rect x="4" y="4" width="16" height="16" rx="2" fill="currentColor"/>
</svg> </svg>
</div> </div>
</vue-advanced-chat> </vue-advanced-chat>
@@ -664,6 +664,30 @@ onUnmounted(() => {
</template> </template>
<style scoped> <style scoped>
.stop-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #f23645;
border-radius: 6px;
transition: color 0.15s ease, transform 0.1s ease, box-shadow 0.15s ease;
cursor: pointer;
}
.stop-btn:hover {
color: #ff4d5e;
transform: scale(1.1);
box-shadow: 0 0 8px rgba(242, 54, 69, 0.4);
}
.stop-btn:active {
color: #c41e2e;
transform: scale(0.92);
box-shadow: 0 0 4px rgba(242, 54, 69, 0.2);
}
.chat-container { .chat-container {
height: 100% !important; height: 100% !important;
display: flex; display: flex;