Bug: "Cannot read properties of undefined (reading 'kind')" when a plugin calls agent.followup() with a bare string #7363
Replies: 3 comments
|
Thanks for the trace — the exact event sequence and the pointer at 1. The write path's only check is a duplicate-identity guard, and what it reads is
|
| placement | control (no plugin) | with a consumer-side guard |
|---|---|---|
string in next-turn, then claimed |
TypeError: reading 'kind' |
turn proceeds |
string in next-step before a claim |
TypeError: reading 'kind' |
turn proceeds |
string injected into next-step while a turn is running |
TypeError: reading 'kind' |
turn proceeds |
The third row is the one I would flag. agent.inject('a string') — the same unvalidated write — puts the value where the next step will look, and agent-instructions walks agent.inbox.nextStep (core/agent-instructions/src/index.ts:321 → :65) and reads message.source.kind. That is a projection read, not a claimed batch, so it is reached before any claim takes the value out. Fix #2 applied to time-context alone leaves a path producing the identical error message, reached from a module you would not have guessed next. Measured against 0.1.6-alpha.2:
control next-step injected mid-turn THREW TypeError: reading 'kind'
guarded next-step injected mid-turn OK nextStep=["msg"]
3. The replay path shares the hole
inboxProjectionSchema (core/agent-loop/src/inbox.ts:21-24) is z.array(z.custom<UserMessage>()) — a cast, not a check:
'next-turn': z.array(z.custom<UserMessage>()).readonly(),Probed against the zod@4.6.5 that dsh-agent-loop itself declares (^4.4.3): a bare string in the wire state parses successfully, while a validating control rejects the same input. So a session that recorded the string is re-accepted on resume — fixing the write boundary does not by itself clean a session that is already affected, and a hand-repaired log is not protected either.
4. On the framing of fix #2
message.source?.kind stops the crash, but there is no meaningful degradation for a claimed batch element that is not a message: the reader would silently skip an entry the user's plugin meant to deliver, turning a loud failure into a quiet one. There are also N such modules (two today — the next one depends on registration order), against one write boundary.
A consumer-side plugin
Until a shape check lands at the write boundary, I've published the consumer half, since the symptoms are exactly what it exists for:
@argszero/cordis-plugin-inbox-input-guard@0.2.0 — source
npm install @argszero/cordis-plugin-inbox-input-guardIt sanitizes the batch at the point the loop takes ownership of it (agent.inbox.claim, so no listener order can matter), sweeps both pending lists at its agent/pre-step entry (which is what closes row 3 above), and observes agent/inbox/spliced so the defect is named at the write — with the session and the log seq — instead of surfacing later as a crash in an unrelated module. A scalar is delivered verbatim as a user message whose source records the repair, so what your plugin meant to say still reaches the model; anything whose text it cannot read unambiguously is dropped rather than guessed at; report mode changes nothing at all and only records.
Evidence: control arms for all three placements above, run against real Cordis, a real AgentLoop, a real durable inbox and the real time-context / agent-instructions readers; 24 behaviour tests; 4 packaging invariants; 20/20 mutations of the built output red; and a probe that runs the same arms against the installed artifact.
Honest boundaries: it cannot rewrite committed history — as you noted, the durable agent/inbox/spliced record keeps whatever was spliced — and it cannot name the plugin that wrote the value, because the inbox records a value and a value carries no caller. The upstream fix is still the shape check; this only keeps a session usable until then.
|
Thanks for the detailed trace and for shipping the consumer-side guard — much appreciated. On our side we went the producer-side route and it is already live: followup() now passes a full message object (id / role / content[] / source.kind) instead of a bare string, with 8 of 8 local assertions passing. Agreed that the shape check at the write boundary is the real fix — we will keep following this discussion for it. |
|
Thanks for closing the loop — good to know the producer side is covered on your install. For anyone still following this thread: the write boundary is unchanged at HEAD So a producer-side shape is the only protection today, and it is per-producer by construction: every caller that reaches The code path is byte-for-byte the one I measured, so the three read placements and the guard experiment above should still reproduce as written — I have not re-run that experiment on |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Bug: "Cannot read properties of undefined (reading 'kind')" when a plugin calls agent.followup() with a bare string
Environment
0.1.5-rc.1(packages@deepseek-ai/dsh,@deepseek-ai/dsh-agent,@deepseek-ai/dsh-agent-loop,@deepseek-ai/dsh-time-contextall0.1.5-rc.1)Symptom
A turn ends with a red error:
{"type":"turn/end","data":{"turn":2,"reason":{"kind":"error","error":{"message":"Cannot read properties of undefined (reading 'kind')","code":"UNKNOWN"}}}}The crash happens in under 1 ms after
turn/start— before anystep/start.Minimal event sequence (durable session log, exact)
Root cause (code-level, traced through the shipped packages)
agent.followup()with a plain string (itsfollowupMessageconfig is a string template).dsh-agent-loopfollowup(input) -> send() -> inbox.splice(...[message])puts the string into the inbox without validation, so the durableagent/inbox/splicedevent carriesinserted: ["<bare string>"], violating theSessionEventMapcontractinserted: UserMessage[].preStep()passes it todispatch.waterfall("agent/pre-step", { messages: [...] }).dsh-time-contextprepend listener callsderiveBrowserTimeZoneContext(requestMessages(...decision.messages)), andbrowserTimeZone(message)does:messagebeing a string,message.sourceisundefined, so this throws exactlyCannot read properties of undefined (reading 'kind').Suggested fixes (defense in depth)
dsh-agent-loop.send()(and/or the inboxmutate) should validate/normalize inbox input — reject or wrap anything that is not aUserMessage. The durable log should never contain a non-message insideinserted.dsh-time-contextbrowserTimeZone()should usemessage.source?.kind(or equivalent guard) so a malformed claimed message degrades instead of crashing the whole turn.Neither fix was present in the commit
32c6866("fix(time-context): preserve empty pre-step decisions"), which is the closest known upstream change to this code path (it addresses empty decisions, not unguarded source reads).Notes
All reactions