Conversation
AgentPlatform exposes goals, actions and conditions as flat, name-keyed views
over every deployed agent, aggregated with distinctBy { it.name }. Two agents
declaring different elements under one name therefore lost one of them: both
agents deployed, agents() reported both, nothing was logged, and the capability
simply stopped existing.
The reach went past planning. PerGoalToolFactory publishes one MCP tool per
goal, so a swallowed goal was a tool that vanished from the MCP server. Because
the survivor is decided by alphabetical agent name, deploying an unrelated agent
could also rebind an already published tool to different behaviour.
Removing the deduplication is not the fix: downstream identifies these elements
by name alone. Goals become MCP tools named after them — duplicates would be an
invalid tool list — and Autonomy asks an LLM to rank goals by name. The
ambiguity has to be prevented, not resolved later.
DefaultAgentPlatform.deploy now refuses an agent whose goal, action or condition
name is already taken by a different deployed agent, naming the offending
element. Elements are compared by value, so agents declaring the very same goal
or sharing one condition instance are unaffected — there is nothing to
disambiguate — and redeploying an agent still replaces it.
BREAKING CHANGE: deploying agents with conflicting element names now throws
IllegalArgumentException. Applications that ran with half their capability
silently missing will fail fast instead.
Signed-off-by: TuanNX <tuannx87@gmail.com>
|
@tuannx - thanks for reporting. Also - appears something got broken due to migration, or its existing issue; could you please try to dig into the history of the problem? |
|
Thanks for the review. Issue opened: #1834 On the history question — this is not from the migration. It goes back to Before: de-duplicate by value. Two identical goals collapse into one, which is That is the only commit that ever touched those lines. The fix restores that distinction rather than removing the de-duplication: Sorry about the dense write-up. Issue and PR description are rewritten in One decision I would rather you made than inherited from a diff: |
|
Thank you, @tuannx - will follow up. Best regards! |
@alexheifetz - could you please advise - conflicting goals:
Should probably align with logic: from PR #1801 |
@tuannx - could you please elaborate on "goal value"? Thank you. |
|
@tuannx @deleSerna @alexheifetz - since we are not sure whether it is an exception or a flagging error, it is a better option - may I suggest considering something like ErroneousAgentExitPolicy with default behavior ERROR. |
AchievesGoal without Action should definitely stop the Agent as it's spec violation and it can be easily fix while developing the Agent itself. Could these conflicting goals/action/conditions belongs to agents from third party libraries? If yes then I do not think there is a straight forward solution to this. But my knowledge here is limited. I always write Agent for a stand alone spring boot application. But, if Agent could also be in 3rd party library then just throwing an exception or just flagging an error also won't help as it's not actionable for the consumer of those conflicting libraries. |
==> That's the reason for suggesting having an error policy configurable. thanks |
|
@igordayen @deleSerna There's a third option, and the annotation path already does it. Annotated goals and the tool naming strategy expects that shape: So two annotated agents can both have a same goal and never collide. Only the That makes the collision impossible instead of reporting it better, and nobody If you agree with the direction I'll rework this PR — the deploy-time check goes |
@igordayen But that would not also fix the real issue when the conflicting actions/goal are coming from multiple agents right?
This could also still result in duplicate name as it's still @igordayen @alexheifetz IMO, we should go in the direction suggested by @tuannx but should use 'Name |
|
Three points raised:
@tuannx - concrete naming examples, please, to substantiate the idea and impact assessment. |
|
Before we choose between throwing, an error policy, or qualification, could we do a @igordayen asked for concrete naming examples and an impact assessment. We can generate Any naming change then shows up as a diff, so we can see what breaks on the wire before We have changed these names unnoticed before: #599 (Claude Desktop rejected a tool name) It would also show that two agents in different packages with the same state class name Happy to open it. This PR would then rebase on top. |
|
thanks @tuannx Was actually inquiring about all patterns on validation logic in the agent validation package - what behavior do they expose by default? Is it consistent? |
DefaultAgentStructureValidator currently report errors ( agent booting won’t stop) for the following cases:
AgentMetadataReader stop the agent for following cases:
AgentMetadataReader reports errors for following case
GoapPathToCompletionValidator reports error for cases where it can not to the goal I have not checked in other places @igordayen do you mean this report? |
|
@deleSerna - thanks for the analysis. So, Duplicate action names ==> already in place, but the algorithm requires refinements. What is the flow? metadata reader ==> validator ==> deployer. Maybe propagate errors up to the deployer, and at the deployer level apply a proper exit policy? Looking for an architecturally sound flow. thanks |
|
@tuannx - Is this PR valid then? As the analysis attached to the issues clearly states that deployments should not be rejected. |
Signed-off-by: TuanNX <tuannx87@gmail.com>
Hi @igordayen: Updated PR to log error only, please review whenever you're available. Thank you! |
@igordayen Thanks, I agree that The current streaming path does not go through Before proceeding, could we clarify only these scope decisions?
|
@tuannx im very confused with inquiries. It appears scope is not defined clearly. I see earlier you stated: " I will make that boundary explicit: collision reporting applies to all capability collections, while hierarchy naming is only for ToolConsumer and generated tool names". |
Thanks @igordayen. And very sorry for bring all the confusion. I see the source of confusion: the PR currently discusses related but separate concerns:
This PR should only apply FULL_HIERARCHY for tool names: LLM, supervisor, and generated MCP tools. |
|
@tuannx - thanks. Please rename PR accordingly. Issue: either rename it too or create subissues for actions. not sure about the name Hierarchy - is not an approach semantics: "fully qualified name" (FQN). I assume action/goal detection on dups separate PR? Separately clear please air on inquiry I posted on both PR and the issue: " "agent-api [GoapPathToCompletionValidator:196] within one agent goal↔action pairing" ==> what is semantics. In rows 1-3 in the table, what is the execution flow? To assess the claim on ValidationErrorCode vs. Logging. fun A ==> Validation Error ==> fun B ==> logging ==> fun C ==> should not be duplicate noisy logging. Therefore trying to get more clarification thanks |
|
@igordayen I answered below:
-> Update this PR and issue #1990
-> Working on it.
-> agentScope.actions.find { it.name == goal.name }If no matching action is found, the validator logs an error and continues without adding a
->
|
Signed-off-by: TuanNX <tuannx87@gmail.com>
Follow-up to embabel#1833 for embabel#1990. Naming context stays out of LlmInteraction: ToolNamingContext decorates the ToolConsumer at each publication boundary, and tools that know their owner declare it via ToolNameOwner. Agent, Action and Goal source names are untouched. - An owner is the agent name and the action's short name, so a published name stays readable: SupervisorWith3Steps_2e_bakeBread rather than the hash an annotation agent's fully qualified action name forces. - ToolNamingStrategy joins owner and tool with "-", which sanitize() always escapes inside a part, so the published name decodes to exactly one (owner, tool) pair. Joining with "_" was ambiguous: "Agent.2e" + "x" and "Agent" + "2e.x" both produced Agent_2e_2e_x. A tool name the owner already ends with is not repeated. - Qualify before distinctBy in ToolConsumer.resolveTools, so two tools with the same short name both survive under FULLY_QUALIFIED. - Qualify tools injected mid-loop through one decorator shared by the blocking and streaming loops. Qualifying is idempotent through any decorator chain, not only when the qualified tool is outermost. - PerGoalToolFactory takes the strategy as a constructor parameter, as it already takes goalToolNamingStrategy; the MCP publishers pass it. Under FULLY_QUALIFIED goals are published per (agent, goal) so a goal is no longer dropped when another agent uses the same name. A goal with several starting input types discriminates by the full class name and lets the strategy bound and hash it, so two input types sharing a simple name stay distinct. - Supervisor prompt lists the published tool names. - DefaultToolDecorator resolves a tool group through the delegate chain of a renamed tool. Tests assert published names at every boundary: the LLM object paths, the supervisor interaction, and the sync and async MCP publishers. Not covered: a non-Action OperationContext resolves its owner to the agent name only, since the LLM boundary receives Action? and embabel#1990 rules out carrying the operation on LlmInteraction. Two actions on one agent whose short names match now share an owner; action names are unique per agent, so this needs a DSL agent naming two actions x.foo and y.foo. embabel-agent-api: 4219 tests, 0 failures; embabel-agent-mcpserver: 91, 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A9DYaVxmFtn54eCbW9fcA3 Signed-off-by: TuanNX <tuannx87@gmail.com>
| */ | ||
| internal class CurriedActionTool( | ||
| private val action: Action, | ||
| internal val action: Action, |
There was a problem hiding this comment.
@tuannx please elaborate on reason for scope change
| internal fun nameFor(tool: Tool, defaultOwnerName: String): String = | ||
| nameFor((tool as? ToolNameOwner)?.ownerName ?: defaultOwnerName, tool.definition.name) | ||
|
|
||
| private fun bound(name: String, source: String): String { |
There was a problem hiding this comment.
cryptic AI generated code, requires KDOC/comments
|
few comments from claude: over-engineering concerns:
and still suggesting to explore whether agentName can be found in blackboard. @azanux - could you please provide feedback at your convinience Thank you |
|
@tuannx: What's happening CurriedActionTool was given agentName: String in its constructor solely to implement ToolNameOwner, so the SupervisorAction → CurriedActionTool.forActions(agentName) → CurriedActionTool(agentName) However, it's avoidable ToolNamingContext.forLlmCall() already derives ownerName from agentProcess.agent.name — which is available val agentName = agentProcess.agent.name ToolNameOwner on CurriedActionTool only overrides that context-level ownerName to use the tool's own The simpler design Drop ToolNameOwner from CurriedActionTool:
Trade AgentA_2e_run-search → AgentA-search. Still globally unique and arguably more readable for the The ToolNamingStrategy / ToolNamingContext abstractions are untouched — only the ToolNameOwner Thanks @azanux, @deleSerna - FYI |
CurriedActionTool implemented ToolNameOwner only to override the owner the publication boundary already derives, which forced an agentName parameter down the construction chain. Since the owner became the agent name and the action's short name, that override no longer changes uniqueness: action names are unique within an agent, so the tool names already differ. Removing it leaves the interface without an implementation, so the interface and the Tool overload that looked it up go too. Curried tools now publish under the supervisor action that resolves them: SupervisorWith3Steps_2e_supervisor-bakeBread. Document the two rules a reader cannot infer: which of owner and tool name is dropped when they overlap, and why an over-long name carries a hash of the unencoded parts. embabel-agent-api: 4217 tests, 0 failures; embabel-agent-mcpserver: 91, 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A9DYaVxmFtn54eCbW9fcA3 Signed-off-by: TuanNX <tuannx87@gmail.com>
Qualification now reads one way everywhere: the owning agent, then the tool name, joined with a hyphen. AgentA and search publish AgentA-search. The action in the owner bought nothing. Every tool in one interaction shares an owner, so the granularity never separated them from each other, and interactions share no namespace, so it separated nothing across them either. The one place names do share a namespace is the generated goal tools, where the owner was already the agent. Dropping the action also removes the inconsistency where a null action already fell back to the agent name, and with it the two branches that existed only to stop an agent-and-action owner repeating a segment. Names lose a segment: SupervisorWith3Steps-bakeBread rather than SupervisorWith3Steps_2e_supervisor-bakeBread, and AgentA-search rather than AgentA_2e_run-search. The action is now unused when resolving an owner, so forLlmCall and resolvePublishedTools no longer take it. The supervisor prompt derives the same owner as the boundary, which a test asserts by comparing the prompt with the tools published in the same request. A goal exported for several starting input types carried the package qualified type, which pushed a typical name past the 64 character bound and into a hash suffix. It now carries the simple name, falling back to the package qualified name only for types that share one, so Wizard-done_2e_UserInput replaces a truncated hash while two types named Frog stay distinct. Tool naming is exercised end to end. RealPipelineLlmOperations fakes only the ModelProvider, so resolution, naming, decoration and the request event all run as they do in production; the existing e2e suite could not observe naming because its fake implements LlmOperations directly. ToolNamingIntegrationTest covers annotation and DSL agents together under each strategy, and asserts that a name published to the model maps back to the function it names. Three collisions survive the design and are recorded as disabled tests rather than hidden; all three fail when enabled. Two agents choosing one explicit export name, since an export name is published verbatim. A goal exporting under a reserved framework name, since allTools concatenates without checking for overlap. Two goals of one name in a single agent, since goals is a Set of a data class and qualification uses the agent name. The digest names its charset rather than relying on a default, so the bounded name is identical wherever it is computed. embabel-agent-api: 4254 tests, 0 failures, 26 skipped; embabel-agent-mcpserver: 212, 0, 3 skipped. Excludes AgentPlatformNameCollisionReportingTest and ArchitectureRules, which are red on the merge base. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A9DYaVxmFtn54eCbW9fcA3 Signed-off-by: TuanNX <tuannx87@gmail.com>
29c5f4d to
48b3aab
Compare
@igordayen Good catch on the owner! I pushed a squashed commit updating it to just the agent name everywhere. What changed:
|
| val namingStrategy = processContext.platformServices.toolNamingStrategy() | ||
| val actionSignatures = tools.filterIsInstance<CurriedActionTool>().joinToString("\n") { tool -> | ||
| val signature = TypeSchemaExtractor.buildActionSignature(tool.action) | ||
| val owner = processContext.agentProcess.agent.name |
|
From Codex:
Open question
@azanux - could you please provide feedback, thanks |
@igordayen Both Codex findings are the two disabled tests in this PR. That is what those tests are for — they record what qualification by owner cannot fix, so it is visible instead of hidden. This is the point I have been trying to get across for a while.
Also: keeping the action in the owner would not have fixed either one. All tools in one interaction go to one call for one action, so the owner is the same either way. Dropping it lost nothing. Your question: keep export.name as an opt-out, but stop being silent. Two agents picking the same export name should be reported at deploy time, using the nameCollisions code we already have. Same for distinctBy — warn instead of dropping quietly. |
azanux
left a comment
There was a problem hiding this comment.
Hi @tuannx, thanks for the work. The naming rule is fine.
My concern is how much code it needs.
Why the code is heavy: the rename happens at the end, right before the LLM call. After that point, the rest of the code no longer knows the tool. So we add helpers to find the old name again.
1. sanitize() encodes too much. It hex-encodes every char outside [a-zA-Z0-9] - even _ and -, which providers accept. So my.tool becomes my_2e_tool, and my_tool becomes my_5f_tool. A simple replace(Regex("[^a-zA-Z0-9_-]"), "_") is enough. We still need bound(): even simple names can pass 64 chars.
2. ToolNamingContext.kt:95 - runCatching { ... }.getOrDefault(LEGACY) is there only for the mocks. Fix the tests instead. This code hides real errors.
3. The rename runs in 4 places: AbstractLlmOperations, ToolResolutionHelper, ToolLoop, Streaming. That is why name() needs the is QualifiedTool check, and why DefaultToolDecorator has a names() helper. One rename point, earlier, removes both.
4. SupervisorAgentFactory:330 - signature.removePrefix(...) fixes the string after building it. Build the signature with the published name instead. Then CurriedActionTool.action can stop being internal.
5. PerGoalToolFactory - does 3 things at once (owner qualification, input-type discrimination, dedup changes). Also, firstOrNull { it.goals.any { g === goal } } scans every agent by reference - the platform should give the owner directly.
Suggestion: split into 4 PRs or comcar je ne vois aps l'intreret mits
To be clear: these are suggestions only, nothing mandatory. The code was just not easy to follow as a reader - that is the main reason for this comment.
(1) strategy + config + one rename point, (2) MCP goal tools, (3) input-type discrimination, (4) collision reporting for #1834.
On @igordayen's question: I agree on export.name. It is a public name the user chose - do not rewrite it. But if two agents pick the same name, report it at deploy time. Do not stay silent.
| ?.takeIf { it.isNotBlank() } | ||
| ?.let { listOf(it, toolName) } | ||
| ?: listOf(toolName) | ||
| bound(parts.joinToString("-") { sanitize(it) }, parts.joinToString("\u0000")) |
There was a problem hiding this comment.
Only the owner needs escaping
sanitize is applied to both the owner (Agent) and the tool name. The join point is the first -, so escaping the owner alone is enough to keep the split unambiguous. Escaping the tool name buys no extra uniqueness.
It does cost length. Every _ or . grows from 1 to 4 characters, and real MCP tool names use underscores heavily (brave_web_search, read_file).
Example:
ToolNamingStrategy.FULLY_QUALIFIED
.nameFor("com.acme.research.DeepResearchAgent", "brave_web_search")
// actual: com_2e_acme_2e_research_2e_DeepResearchAgent-brave__38a4886d2533
// expected: com_2e_acme_2e_research_2e_DeepResearchAgent-brave_web_searchThe encoded name is 67 characters, over MAX_NAME_LENGTH = 64, so bound() truncates it and appends a hash. The model can no longer tell which tool it is calling. Dropping the tool-side escaping brings the same name to 61 characters and it stays readable.
Suggestion: escape the owner as it is now, and on the tool side escape only characters a provider would reject (anything outside [a-zA-Z0-9_-]). That keeps _ and - at one character while every published name still matches ^[a-zA-Z0-9_-]{1,64}$.
There was a problem hiding this comment.
Agreed that escaping common characters makes names longer and less readable. However, escaping only the owner protects the owner/tool separator, not the encoding within the tool name.
For example, preserving _ while keeping the current encoding for . would produce:
read.file → AgentA-read_2e_file
read_2e_file → AgentA-read_2e_file
The current underscore escaping keeps these distinct. I suggest retaining it for now, or replacing it with a shared encoding rule that improves readability while preserving this distinction across all publication paths.
| ToolNamingStrategy.LEGACY_NAME_ONLY -> goalToolNamingStrategy.nameForGoal(goal) | ||
| ToolNamingStrategy.FULLY_QUALIFIED -> toolNamingStrategy.nameFor(ownerName, goal.name) | ||
| } | ||
| exportName != null -> toolNamingStrategy.nameFor(exportName, discriminator) |
There was a problem hiding this comment.
Agreed with @tuannx on the principle: export.name is a public name the user chose for outside clients, so it should be published as-is, and a deploy-time warning on duplicates is the right answer rather than qualifying it.
One place does not follow that principle. With a single starting input type the name goes out untouched. With two or more it is passed through sanitize , so the pinned name is rewritten:
1 input type -> my.export
2 input types -> my_2e_export-UserInput
The -UserInput suffix is expected - one goal becomes two tools and they have to differ. Turning my.export into my_2e_export is not. Adding an input type silently renames a tool an outside client may already be calling, which is the exact thing publishing it as-is was meant to protect.
If export names are trusted, they should be trusted on both branches: my.export and my.export-UserInput.
Related, same value: line 179 skips bound() as well, so an export name over 64 characters is never truncated
There was a problem hiding this comment.
You’re right: preserving an explicit name for one input but encoding it for multiple inputs is inconsistent.
Current: my.export → my_2e_export-UserInput
Proposed: my.export → my.export-UserInput
Preserving the explicit base consistently makes sense. However, adding the suffix still changes the public name, so this does not preserve existing calls to my.export when another input type is added. That behavior needs to be explicit in the contract.
If an explicit name, including its suffix, exceeds the publication target’s limits, I would prefer a clear validation error over silently shortening it.
| } | ||
| } | ||
|
|
||
| private fun goalsToPublish(): List<GoalSource> = if (toolNamingStrategy == ToolNamingStrategy.FULLY_QUALIFIED) { |
There was a problem hiding this comment.
@igordayen Feedback on the open question:
Duplicate export.name should stay an opt-out from qualification — but not from detection.
export.name is a name the user chose as a public contract for outside clients. Qualifying or rewriting it would silently break a name those clients may already be calling, so uniqueness of an explicit export name has to remain the user's responsibility. What the platform owes them is to say when they got it wrong: report duplicate published names at deploy time via the existing nameCollisions support, instead of the second tool silently masking the first.
One scope note for the implementation: under FULLY_QUALIFIED, goalsToPublish() iterates agents() directly and bypasses the distinctBy { it.name } that AgentPlatform.goals applies - so the duplicate check should run on published names in goalTools(), not rely on the legacy goal-level dedup.
There was a problem hiding this comment.
Agreed: explicit names should opt out of qualification, not collision detection. The check should cover the complete final tool list, including generated names and platform tools:
Explicit "search" ↔ another explicit "search"
Explicit "AgentA-search" ↔ generated "AgentA-search"
Explicit "_confirm" ↔ platform tool "_confirm"
Checking only goalTools() would miss the platform-tool case. This is a publication-boundary check, not necessarily a deploy-time check, and I couldn’t find the earlier nameCollisions support on the current head.
Since validation is outside the current PR scope, I suggest agreeing on a narrow publication check or a follow-up. A warning alone would expose the collision but leave ambiguous registration or routing unresolved.
@azanux Thanks for these points. |
Target
Apply one provider-neutral fully qualified name (FQN) strategy to published tools across:
Design
embabel-agent-apicore.LlmInteraction.Agent.name,Action.name, andGoal.nameunchanged.Out of scope
AgentPlatformaggregation collisionsAgent.namehandling during deploymentRelated issue: #1990
Parent issue: #1834