fix(web-app,agent-presets): keep the goal service on the host plane

The Gateway serves the goal domain as Remote endpoints, and a Remote method
picks its receiver Service from a generated descriptor — `clear(agent, ref)`
takes its agent as a PARAMETER, so the invocation is direct and the receiver
resolves on the host. Behind the preset's entry-local realm there was nothing
to resolve, and every browser goal call answered `service-unavailable`: the
composer's Clear goal button left the bar on screen.

That is the `bash-env` criterion read from the other side. Injection is not the
only host relationship a Service can have; being READ from a host row is one
too. The registry is keyed by session, so one host instance serves every
session exactly as it did before presets. The preset keeps the model-facing
tool, which is the choice a preset is for.

Also reverts the cold-transcript presenter resolve: resuming an agent to reach
its presenters made the context meter drop its cache and token counts on every
cold-opened session, trading one silent degradation for another. The card
degradation it addressed is diagnosed and still open.
This commit is contained in:
Yichen Jiang
2026-08-08 15:53:39 +08:00
parent 1c80023b12
commit a21644af59
4 changed files with 21 additions and 84 deletions

View File

@@ -237,14 +237,14 @@
- id: tool-skill
disabled: true
- id: goal
disabled: true
- id: goal-session
disabled: true
- id: command-goal
disabled: true
# The goal SERVICE, its session driver, and the `/goal` command STAY on the
# host plane; only the model-facing tool moves. The Gateway serves the goal
# domain as Remote endpoints, and a Remote method picks its receiver Service
# from a generated descriptor — it resolves `goals` on the host, so a
# per-session realm would answer `service-unavailable` for every browser call.
# That is the `bash-env` criterion read from the other side: injection is not
# the only host relationship a Service can have. The registry is keyed by
# session, so one host instance serves every session exactly as before presets.
- id: tool-goal
disabled: true

View File

@@ -1127,30 +1127,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return undefined
}
/**
* The agent whose layer holds this session's tool presenters.
*
* A preset registers its tools into the agent's OWN layer, so a transcript
* read while no agent exists finds no presenter at all and every card
* degrades to the generic renderer — silently, because a viewless entry is
* also what a tool with no presenter produces. Reading therefore resolves
* the agent, through the same deduplicated resume every other session method
* takes, whenever a roster is composed.
*
* A deployment composing no roster keeps the storage-only read it always
* had: its tools are in the host layer, which needs no agent to address. A
* resolution failure is not a read failure either — the transcript still
* serves, with the generic cards it would have rendered anyway.
* @param sessionId - the transcript being read.
* @returns the agent to resolve presenters against, or undefined for none.
*/
async function presenterAgentFor(sessionId: SessionId): Promise<Agent | undefined> {
const live = ctx.get('agents')?.get(sessionId)
if (live !== undefined || ctx.get('agentPresets') === undefined) return live
const found = await agentFor(sessionId)
return 'error' in found ? undefined : found.agent
}
/** Read one transcript cut and optional projection baseline without acquiring an Agent owner. */
async function historyStateFor(
sessionId: SessionId,
@@ -1780,7 +1756,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
details: {},
})
}
const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterAgentFor(sessionId))
// `ctx.get`, not `ctx.agents`: this is the COLD path, and a caller may
// serve history from storage with no agent registry composed at all.
// An absent registry means no live agent, which is the same answer a
// present one gives here — presenters fall back to the global layer.
const page = historyPage(ctx, state.events, beforeSeq, maxMessages, ctx.get('agents')?.get(sessionId))
return ok(request, {
events: page.events,
hasMore: page.hasMore,

View File

@@ -239,37 +239,3 @@ describe('a capability the session\'s preset mounts', () => {
expect(failure.error.message).toContain('neither this session')
})
})
describe('session.history presenters', () => {
it('resolves the live agent so a preset-composed presenter is reachable', async () => {
const { api, ctx } = await harness(['standard'])
await api.sessions.create(request({ sessionId: SessionId('h1'), agentPreset: 'standard' }))
const response = await api.sessions.history(request({ sessionId: SessionId('h1') }))
expect(response.result.ok).toBe(true)
expect(ctx.agents.get(SessionId('h1'))).toBeDefined()
})
it('serves the transcript when no agent can be resolved for it', async () => {
// The roster is composed, so the read tries; this harness resumes nothing.
// A resolution failure is not a read failure — the transcript still
// serves, with the generic cards a viewless entry renders.
const { api } = await harness(['standard'])
const response = await api.sessions.history(request({ sessionId: SessionId('h2') }))
expect(response.result.ok).toBe(false)
const failure = response.result as { ok: false; error: { code: string } }
expect(failure.error.code).toBe('session-not-found')
})
it('keeps the storage-only read when the deployment composes no roster', async () => {
const { api } = await harness()
await api.sessions.create(request({ sessionId: SessionId('h3') }))
const response = await api.sessions.history(request({ sessionId: SessionId('h3') }))
expect(response.result.ok).toBe(true)
})
})