fix(subagent): confirm steering request admission
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/subagent/subagent-inprocess/README.md
|
||||
README.md: eb5d973566f01c05b43f4f56eff746b7af93f60b
|
||||
README.zh.md: 5be640f9b6da6402ece0e1d15997d9e2970a7d1c
|
||||
README.md: 6225b84f1274b61cae1d4ca567155dcc6e6a0888
|
||||
README.zh.md: 5c3ab3baa3ab86f33fe34026ddbdf97449cb4f92
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
This package is the shared run driver for the two in-process providers. Spawn passes no session seed; fork passes the parent's completed-turn prefix. Everything else—depth, child creation and cold resume, optional child customization, result reading, cancellation, strict steering, and disposal—has one implementation here.
|
||||
This package is the shared run driver for the two in-process providers. Spawn passes no session seed; fork passes the parent's completed-turn prefix. Everything else—depth, child creation and cold resume, optional child customization, result reading, cancellation, confirmed steering, and disposal—has one implementation here.
|
||||
|
||||
## Start contract
|
||||
|
||||
@@ -31,7 +31,7 @@ The required request signal covers both startup and the live run. Before publica
|
||||
|
||||
After fulfillment, the caller owns the run. Provider-plugin unload does not revoke it. `dispose()` removes the live abort listener, records cancellation, and delegates to the returned `AgentHandle.dispose()`, whose memoized quiescence transaction stops the loop, removes the agent and session, and unwinds scoped registrations. Cancellation owns every non-completed in-flight outcome and reports `aborted`; an already-completed turn remains completed.
|
||||
|
||||
Runs expose the strict `steer` capability: the synchronous checks and the `Agent.trySteer()` call share one frame, so delivery joins the observed step or throws. Delivery requires `AgentStatus.running`, an open turn and step in the child log, no committed structured capture, and acceptance before that step's final drain begins. Admission, between-step processing such as `agent/turn-stopping`, and a closed turn's durability flush all reject delivery. The Agent-level idle fallback (queue and start a new turn) is deliberately not reachable through the run — that would start an untracked turn after the run's result was read.
|
||||
Runs expose confirmed `steer`: a synchronous status check prevents the Agent-level idle fallback from starting an untracked turn, then the run submits through `Agent.steer()` and awaits that exact message's receipt. Fulfillment means a committed child request snapshot admitted the message; terminal turn policy, cancellation, disposal, or a settlement race rejects instead. A synchronously visible structured capture is rejected before submission because its terminal outcome is already authoritative. The run never falls through from rejected live delivery to a later queued turn or cold resume.
|
||||
|
||||
## Spawn and fork inputs
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
本包是两个进程内提供方共用的运行驱动器。spawn 不传入会话初始内容;fork 传入父 agent(智能体)已完成轮次的前缀。其余机制,包括深度、子 agent 创建与冷恢复、可选的子 agent 定制、结果读取、取消、严格 steering(中途引导)和 dispose(资源释放),都在此共用同一套实现。
|
||||
本包是两个进程内提供方共用的运行驱动器。spawn 不传入会话初始内容;fork 传入父 agent(智能体)已完成轮次的前缀。其余机制,包括深度、子 agent 创建与冷恢复、可选的子 agent 定制、结果读取、取消、确认式 steering(中途引导)和 dispose(资源释放),都在此共用同一套实现。
|
||||
|
||||
## 启动契约
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
兑现后,调用方拥有该运行。提供方插件卸载不会撤销它。`dispose()` 会移除实时中止监听器、记录取消,并委托给返回的 `AgentHandle.dispose()`;后者通过可复用的完全停稳事务停止循环、移除 agent 和会话,并展开有作用域的注册。取消决定所有尚未完成的进行中结果,并将其报告为 `aborted`;已经完成的轮次仍保持完成状态。
|
||||
|
||||
运行公开严格的 `steer` 功能:同步检查与 `Agent.trySteer()` 调用位于同一个调用栈帧中,因此消息要么加入观察到的步骤,要么抛错。交付要求 `AgentStatus.running`、子 agent 日志中有开放的轮次和步骤、没有已提交的结构化捕获,并且在该步骤的最终 drain 开始前获接纳。提示词接纳、`agent/turn-stopping` 等步骤间处理,以及已关闭轮次的持久性 flush 都会拒绝交付。运行不会触达 Agent 层在空闲时排队并启动新轮次的 fallback;否则会在运行结果读取后启动一个未被跟踪的轮次。
|
||||
运行公开确认式 `steer`:同步状态检查会阻止 Agent 层的空闲 fallback 启动未跟踪轮次,随后运行通过 `Agent.steer()` 提交消息,并等待该准确消息的回执。兑现表示某个已提交的子 agent 请求 snapshot 接纳了消息;结束轮次的策略、取消、dispose(资源释放)或结算竞态会改为拒绝。已同步可见的结构化捕获会在提交前被拒绝,因为其终态结果已经具有权威性。实时投递被拒绝后,运行绝不会转而进入之后的排队轮次或冷恢复。
|
||||
|
||||
## Spawn 与 fork 输入
|
||||
|
||||
|
||||
@@ -238,8 +238,8 @@ export async function resumeInProcessRun(request: SubagentResumeRequest): Promis
|
||||
* Drive one activation turn on a published child and wrap it as a run. The
|
||||
* caller has already created or resumed the agent; this owns the
|
||||
* signal-handoff race, the live abort listener, result collection past
|
||||
* `boundary`, the continuable-run durability confirmation, strict steering,
|
||||
* and disposal.
|
||||
* `boundary`, the continuable-run durability confirmation, confirmed
|
||||
* steering, and disposal.
|
||||
*/
|
||||
function driveTurn(
|
||||
handle: AgentHandle,
|
||||
@@ -299,46 +299,21 @@ function driveTurn(
|
||||
flags.cancelled = true
|
||||
return handle.dispose()
|
||||
},
|
||||
steer(content: ContentBlock[], steeringSource: MessageSource): void {
|
||||
// Strict live delivery: the synchronous checks and Agent.trySteer() share
|
||||
// one frame, so delivery joins the observed step or throws. The ordinary
|
||||
// Agent.steer() idle fallback would instead queue the message and
|
||||
// start a new, untracked turn after this run's result was read.
|
||||
async steer(content: ContentBlock[], steeringSource: MessageSource): Promise<void> {
|
||||
// The status check and submission share one synchronous frame. An idle
|
||||
// Agent.steer() would queue an untracked turn after this run's result.
|
||||
if (child.status !== 'running') {
|
||||
throw new Error(`subagent child "${childId}" is not running; the message was not delivered`)
|
||||
}
|
||||
// Status stays `running` through the closed turn's durability flush, when
|
||||
// ordinary steering would queue a later turn. Requiring an open turn
|
||||
// keeps this activation's acknowledged delivery honest.
|
||||
const lastBoundary = child.session.events.findLast(
|
||||
event => event.type === 'turn/start' || event.type === 'turn/end',
|
||||
)
|
||||
if (lastBoundary?.type !== 'turn/start') {
|
||||
throw new Error(`subagent child "${childId}" turn has already closed; the message was not delivered`)
|
||||
}
|
||||
// Between steps there is no current step whose final drain can own strict
|
||||
// delivery. A message accepted during an open step is recorded at that
|
||||
// step's settlement checkpoint before the continuation decision
|
||||
// (cancellation remains the documented shared-outcome race).
|
||||
const lastStep = child.session.events.findLast(
|
||||
event => event.type === 'step/start' || event.type === 'step/end',
|
||||
)
|
||||
if (lastStep?.type !== 'step/start') {
|
||||
throw new Error(`subagent child "${childId}" is between steps; the message was not delivered`)
|
||||
}
|
||||
// A committed structured capture makes the pending step conclusion
|
||||
// terminal. The capture is synchronously observable, so reject rather
|
||||
// than acknowledge a message the run is about to drop.
|
||||
// Avoid waiting for the structured terminal checkpoint when its outcome
|
||||
// is already authoritative and synchronously visible.
|
||||
if (structured?.captured() !== undefined) {
|
||||
throw new Error(`subagent child "${childId}" already reported its structured result; the message was not delivered`)
|
||||
}
|
||||
// The atomic Agent operation closes before the final drain, so this
|
||||
// cannot acknowledge content that the current step will not record.
|
||||
if (child.trySteer === undefined) {
|
||||
throw new Error(`subagent child "${childId}" agent does not support strict steering; the message was not delivered`)
|
||||
}
|
||||
if (!child.trySteer(createUserMessage({ content, source: steeringSource }))) {
|
||||
throw new Error(`subagent child "${childId}" passed its steering checkpoint; the message was not delivered`)
|
||||
const receipt = child.steer(createUserMessage({ content, source: steeringSource }))
|
||||
const outcome = await receipt.outcome
|
||||
if (outcome.status === 'rejected') {
|
||||
throw new Error(`subagent child "${childId}" stopped before steering admission; the message was not delivered`)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -120,26 +120,24 @@ describe('in-process structured output', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('strict steer rejects delivery once the structured result is captured', async () => {
|
||||
it('confirmed steering rejects delivery once the structured result is captured', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
|
||||
])
|
||||
// oxlint-disable-next-line prefer-const -- single assignment follows listener registration so pre-fulfillment events remain guardable.
|
||||
let run: Awaited<ReturnType<typeof ctx.subagents.start>> | undefined
|
||||
let rejected: unknown
|
||||
let delivery: Promise<void> | undefined
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session.header.parentSession === undefined || run === undefined
|
||||
|| event.type !== 'tool/result' || rejected !== undefined) return
|
||||
try {
|
||||
run.steer?.([{ type: 'text', text: 'one more thing' }], { kind: 'user' })
|
||||
} catch (error: unknown) {
|
||||
rejected = error
|
||||
}
|
||||
|| event.type !== 'tool/result' || delivery !== undefined) return
|
||||
delivery = run.steer?.([{ type: 'text', text: 'one more thing' }], { kind: 'user' })
|
||||
void delivery?.catch(() => undefined)
|
||||
})
|
||||
run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const result = await run.result
|
||||
expect(rejected).toBeInstanceOf(Error)
|
||||
expect((rejected as Error).message)
|
||||
.toMatch(/already reported its structured result; the message was not delivered/)
|
||||
if (delivery === undefined) throw new Error('structured result did not submit steering')
|
||||
await expect(delivery)
|
||||
.rejects.toThrow(/already reported its structured result; the message was not delivered/)
|
||||
expect(result.structured).toEqual({ answer: 7 })
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
@@ -10,7 +10,8 @@ import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
|
||||
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
||||
import SubagentService, { SUBAGENT_DESCRIPTOR_VERSION, SubagentError } from '@deepseek-ai/dsh-subagent'
|
||||
import { maxTokensResponse, MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { resumeInProcessRun, startInProcessRun } from '../src/index.ts'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
@@ -290,7 +291,7 @@ describe('startInProcessRun', () => {
|
||||
reserveTurnAdmission: () => undefined,
|
||||
updateInbox: () => 'not-found',
|
||||
followup(): void {},
|
||||
steer(): void {},
|
||||
steer() { return { outcome: Promise.resolve({ status: 'rejected' as const }) } },
|
||||
inject(): void {},
|
||||
cancel(): void {},
|
||||
whenIdle: () => Promise.resolve(),
|
||||
@@ -381,156 +382,103 @@ describe('startInProcessRun', () => {
|
||||
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
|
||||
})
|
||||
|
||||
it('strict steer rejects a settled child instead of queueing an untracked turn', async () => {
|
||||
it('confirmed steering rejects a settled child instead of queueing an untracked turn', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const run = await startInProcessRun(request(parent), {})
|
||||
await run.result
|
||||
// The child is idle after its turn: Agent.steer() would silently QUEUE.
|
||||
expect(() => { run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' }) })
|
||||
.toThrow(/not running; the message was not delivered/)
|
||||
await expect(run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' }))
|
||||
.rejects.toThrow(/not running; the message was not delivered/)
|
||||
const child = ctx.agents.get(run.id)!
|
||||
expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('strict steer rejects the between-steps turn-stopping window', async () => {
|
||||
// Hold `agent/turn-stopping` open after the step closed and pending
|
||||
// steering was folded into the continuation decision.
|
||||
const { ctx, parent } = await setup([textResponse('quick')])
|
||||
let releaseStop: (() => void) | undefined
|
||||
ctx.on('agent/turn-stopping', (agent) => {
|
||||
if (agent.session.header.parentSession === undefined || releaseStop !== undefined) return undefined
|
||||
return new Promise((resolve) => {
|
||||
releaseStop = () => { resolve(undefined) }
|
||||
})
|
||||
})
|
||||
const run = await startInProcessRun(request(parent), {})
|
||||
const child = ctx.agents.get(run.id)!
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setInterval(() => {
|
||||
if (releaseStop !== undefined) { clearInterval(timer); resolve() }
|
||||
}, 5)
|
||||
})
|
||||
expect(child.status).toBe('running')
|
||||
expect(() => {
|
||||
run.steer!([{ type: 'text', text: 'too late for this turn' }], { kind: 'user' })
|
||||
})
|
||||
.toThrow(/between steps; the message was not delivered/)
|
||||
releaseStop!()
|
||||
await run.result
|
||||
expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('strict steer rejects reentrant delivery after the final drain begins', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('quick')])
|
||||
let run: Awaited<ReturnType<typeof startInProcessRun>> | undefined
|
||||
let seeded = false
|
||||
let rejected: unknown
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session.header.parentSession === undefined || run === undefined) return
|
||||
if (event.type === 'assistant/chunk' && !seeded) {
|
||||
seeded = true
|
||||
run.steer?.([{ type: 'text', text: 'accepted before the drain' }], { kind: 'user' })
|
||||
} else if (event.type === 'steering/message' && rejected === undefined) {
|
||||
try {
|
||||
run.steer?.([{ type: 'text', text: 'after the drain began' }], { kind: 'user' })
|
||||
} catch (error: unknown) {
|
||||
rejected = error
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
run = await startInProcessRun(request(parent), {})
|
||||
const child = ctx.agents.get(run.id)!
|
||||
await run.result
|
||||
expect(seeded).toBe(true)
|
||||
expect(rejected).toBeInstanceOf(Error)
|
||||
expect((rejected as Error).message)
|
||||
.toMatch(/passed its steering checkpoint; the message was not delivered/)
|
||||
expect(child.session.events.filter(event => event.type === 'steering/message')).toHaveLength(1)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('strict steer rejects an Agent implementation without atomic steering', async () => {
|
||||
const childId = SessionId('custom-loop-child')
|
||||
const childSession = new Session(childId)
|
||||
childSession.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
childSession.append('step/start', { turn: 1, step: 1 })
|
||||
const idle = Promise.withResolvers<undefined>()
|
||||
const child = {
|
||||
id: childId,
|
||||
options: {},
|
||||
session: childSession,
|
||||
status: 'running',
|
||||
acceptsNextStep: false,
|
||||
ctx: new Context(),
|
||||
send(): void {},
|
||||
reserveTurnAdmission: () => undefined,
|
||||
updateInbox: () => 'not-found',
|
||||
followup(): void {},
|
||||
steer(): void {},
|
||||
inject(): void {},
|
||||
cancel(): void {},
|
||||
whenIdle: () => idle.promise,
|
||||
} as Agent
|
||||
const parentId = SessionId('custom-loop-parent')
|
||||
const parent = {
|
||||
id: parentId,
|
||||
options: {},
|
||||
session: new Session(parentId),
|
||||
ctx: {
|
||||
get: () => undefined,
|
||||
agents: {
|
||||
create: () => Promise.resolve({
|
||||
agent: child,
|
||||
dispose: () => {
|
||||
idle.resolve(undefined)
|
||||
return Promise.resolve()
|
||||
},
|
||||
}),
|
||||
},
|
||||
it('confirmed steering rejects when a concluding tool prevents request admission', async () => {
|
||||
const { ctx, parent } = await setup([toolCallResponse('c1', 'finalize', {})])
|
||||
const enteredTool = Promise.withResolvers<undefined>()
|
||||
const releaseTool = Promise.withResolvers<undefined>()
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'finalize',
|
||||
description: 'Finish the child run.',
|
||||
parameters: {},
|
||||
async execute(_args, exec) {
|
||||
enteredTool.resolve(undefined)
|
||||
await releaseTool.promise
|
||||
exec.concludeTurn()
|
||||
return [{ type: 'text', text: 'final' }]
|
||||
},
|
||||
} as unknown as Agent
|
||||
|
||||
const run = await startInProcessRun(request(parent), {})
|
||||
expect(() => {
|
||||
run.steer!([{ type: 'text', text: 'unsupported strict delivery' }], { kind: 'user' })
|
||||
})
|
||||
.toThrow(/does not support strict steering; the message was not delivered/)
|
||||
await run.dispose()
|
||||
await run.result
|
||||
})
|
||||
|
||||
it('strict steer rejects the closed-turn flush window where the loop discards steering', async () => {
|
||||
// Hold the turn-end durability flush open: the turn has closed in the log
|
||||
// and status is still `running`, exactly the window where the loop would
|
||||
// discard a drained steering message instead of recording it.
|
||||
const { ctx, parent } = await setup([textResponse('quick')])
|
||||
let releaseFlush: (() => void) | undefined
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (session.header.parentSession === undefined || releaseFlush !== undefined) return
|
||||
const lastEnd = session.events.findLast(event => event.type === 'turn/end')
|
||||
if (lastEnd === undefined) return
|
||||
return new Promise<void>((resolve) => { releaseFlush = resolve })
|
||||
})
|
||||
}))
|
||||
const run = await startInProcessRun(request(parent), {})
|
||||
const child = ctx.agents.get(run.id)!
|
||||
// Wait until the child's turn has closed while the flush keeps it running.
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setInterval(() => {
|
||||
if (releaseFlush !== undefined) { clearInterval(timer); resolve() }
|
||||
}, 5)
|
||||
})
|
||||
expect(child.status).toBe('running')
|
||||
expect(() => { run.steer!([{ type: 'text', text: 'into the void' }], { kind: 'user' }) })
|
||||
.toThrow(/turn has already closed; the message was not delivered/)
|
||||
releaseFlush!()
|
||||
await enteredTool.promise
|
||||
|
||||
const delivery = run.steer!([{ type: 'text', text: 'terminal race' }], { kind: 'user' })
|
||||
releaseTool.resolve(undefined)
|
||||
await expect(delivery).rejects.toThrow(/stopped before steering admission; the message was not delivered/)
|
||||
await run.result
|
||||
expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('confirmed steering fulfills only after the next request snapshot admits it', async () => {
|
||||
const { ctx, parent, adapter } = await setup([textResponse('first'), textResponse('second')])
|
||||
const enteredStopping = Promise.withResolvers<undefined>()
|
||||
const releaseStopping = Promise.withResolvers<undefined>()
|
||||
let held = false
|
||||
ctx.on('agent/turn-stopping', (agent) => {
|
||||
if (agent.session.header.parentSession === undefined || held) return
|
||||
held = true
|
||||
enteredStopping.resolve(undefined)
|
||||
return releaseStopping.promise
|
||||
})
|
||||
|
||||
const run = await startInProcessRun(request(parent), {})
|
||||
const child = ctx.agents.get(run.id)!
|
||||
await enteredStopping.promise
|
||||
|
||||
let settled = false
|
||||
const delivery = run.steer!([{ type: 'text', text: 'after the first step' }], { kind: 'user' })
|
||||
.then(() => { settled = true })
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
releaseStopping.resolve(undefined)
|
||||
await delivery
|
||||
|
||||
const result = await run.result
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('after the first step')
|
||||
expect((result.output[0] as { text?: string }).text).toBe('second')
|
||||
const steering = child.session.events.find(event => event.type === 'steering/message')
|
||||
expect(steering?.type === 'steering/message' && steering.data.message.source).toEqual({ kind: 'user' })
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('carries steering from a non-terminal flush window into a tracked next turn', async () => {
|
||||
const { ctx, parent, adapter } = await setup([textResponse('first'), textResponse('second')])
|
||||
const enteredFlush = Promise.withResolvers<undefined>()
|
||||
const releaseFlush = Promise.withResolvers<undefined>()
|
||||
let held = false
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (session.header.parentSession === undefined || held) return
|
||||
if (!session.events.some(event => event.type === 'turn/end')) return
|
||||
held = true
|
||||
enteredFlush.resolve(undefined)
|
||||
return releaseFlush.promise
|
||||
})
|
||||
|
||||
const run = await startInProcessRun(request(parent), {})
|
||||
const child = ctx.agents.get(run.id)!
|
||||
await enteredFlush.promise
|
||||
expect(child.status).toBe('running')
|
||||
|
||||
const delivery = run.steer!([{ type: 'text', text: 'next tracked turn' }], { kind: 'user' })
|
||||
releaseFlush.resolve(undefined)
|
||||
await delivery
|
||||
const result = await run.result
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(child.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false)
|
||||
expect((result.output[0] as { text?: string }).text).toBe('second')
|
||||
await run.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user