Copilot commented on code in PR #1533:
URL: https://github.com/apache/dubbo-admin/pull/1533#discussion_r3817917917
##########
ai/component/server/engine/models.go:
##########
@@ -37,9 +36,8 @@ func NewErrorResponse(message string) *Response {
// ChatRequest defines streaming chat request
type ChatRequest struct {
- Message string `json:"message" binding:"required"` // User
message
- SessionID string `json:"sessionID" binding:"required"` //
Session ID
- Context json.RawMessage `json:"context,omitempty"` //
Current-turn page context
+ Message string `json:"message" binding:"required"` // User message
+ SessionID string `json:"sessionID" binding:"required"` // Session ID
Review Comment:
The backend now silently drops the page context that the current frontend
still sends. `ui-vue3/src/api/service/ai.ts:68-72` includes `context` in every
selected-context request, and its unit test explicitly verifies that contract;
with this field and the handler plumbing removed, those requests still succeed
but the agent loses the user's selected page state. Preserve the context
contract, or update/remove the frontend feature in the same change rather than
silently ignoring it.
##########
ai/component/models/models.yaml:
##########
@@ -1,18 +1,12 @@
type: models
spec:
- default_model: "dashscope/qwen3.7-max"
+ default_model: "dashscope/qwen-max"
Review Comment:
This also changes the configured production/default model family
(`models.yaml` switches from qwen3.7-max to qwen-max, while `agent.yaml`
switches to qwen3.5-plus) and removes the qwen3.7 registrations. That migration
is independent of the described ReAct/config-shape refactor and can materially
change answer quality, latency, and cost. Retain the existing models, or
explicitly document and verify the model migration as part of this PR.
##########
ai/component/agent/react/steps.go:
##########
@@ -34,248 +32,123 @@ import (
"github.com/firebase/genkit/go/ai"
)
-// buildSteps materializes the step closures for one interaction, binding the
-// per-interaction channels so progress/streaming reaches the right consumer.
-func (ra *ReActAgent) buildSteps(chans *agent.Channels) []step {
- steps := make([]step, 0, len(ra.stages))
- for _, st := range ra.stages {
- switch st.kind {
- case flowReasonAct:
- steps = append(steps, ra.reasonActStep(st.prompt,
chans, st.timeout))
- case flowObserve:
- steps = append(steps, ra.observeStep(st.prompt, chans,
st.timeout))
- }
- }
- return steps
-}
-
-// historyFromCtx pulls the session-scoped history out of ctx, replacing the
-// pointer/value assertion churn the old flows repeated at every stage.
-func historyFromCtx(ctx context.Context) (*memory.HistoryMemory, string,
error) {
- history, ok := ctx.Value(memory.ChatHistoryKey).(*memory.HistoryMemory)
- if !ok {
- return nil, "", fmt.Errorf("failed to get history from context")
- }
- sessionID, ok := ctx.Value(memory.SessionIDKey).(string)
- if !ok || sessionID == "" {
- return nil, "", fmt.Errorf("session id not found in context")
- }
- return history, sessionID, nil
-}
-
-// reasonActStep merges the old think + act stages: one model call reasons
about
-// the request and, via native function calling, either issues tool requests
-// (which it executes) or issues none (answering directly). The observe stage
-// then composes the reply, so this step never terminates the loop.
-func (ra *ReActAgent) reasonActStep(prompt ai.Prompt, chans *agent.Channels,
timeout time.Duration) step {
- return func(ctx context.Context, s *state) (bool, error) {
- emitStageProgress(chans, flowReasonAct, true)
- defer emitStageProgress(chans, flowReasonAct, false)
-
- history, sessionID, err := historyFromCtx(ctx)
- if err != nil {
- return false, err
- }
- if history.IsEmpty(sessionID) {
- return false, fmt.Errorf("history is empty")
- }
- messages, err := injectCurrentPageContext(ctx,
history.WindowMemory(sessionID))
- if err != nil {
- return false, err
+// run drives the reason-and-act loop for one interaction. Each iteration is a
+// single model call: with native function calling the model either requests
+// tools (whose results are fed back as context for the next iteration) or
+// answers directly — a tool-free response IS the final answer, so no separate
+// "observe" reasoning step is needed to decide when to stop. The last allowed
+// iteration uses the tool-less answer prompt so the loop always terminates
with
+// a real answer rather than an exhausted-budget silence.
+//
+// run streams the answer itself and returns the interaction's accumulated
token
+// usage; the caller emits the final usage marker and closes the channels.
+func (ra *ReActAgent) run(ctx context.Context, chans *agent.Channels)
(*ai.GenerationUsage, error) {
+ history, sessionID, err := historyFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if history.IsEmpty(sessionID) {
+ return nil, fmt.Errorf("history is empty")
+ }
+
+ usage := &ai.GenerationUsage{}
+ for i := 0; i < ra.maxIterations; i++ {
+ // The final iteration must answer: drop the tools so the model
can only
+ // synthesize from what it has already gathered.
+ forceAnswer := i == ra.maxIterations-1
+ prompt := ra.actPrompt
+ if forceAnswer {
+ prompt = ra.answerPrompt
}
- // Only the model call is bound by the stage timeout; tool
execution below
- // runs on the original ctx so a slow reasoning step can't
starve the tools
- // it just asked for (which would otherwise fail hard on the
shared deadline).
- lctx, cancel := withTimeout(ctx, timeout)
- resp, err := prompt.Execute(lctx, ai.WithMessages(messages...))
+ // Only the model call is bound by the per-call timeout; tool
execution
+ // below runs on the original ctx so a slow reasoning step
can't starve the
+ // tools it just asked for on a shared deadline.
+ lctx, cancel := withTimeout(ctx, ra.callTimeout)
+ resp, err := prompt.Execute(lctx,
ai.WithMessages(history.WindowMemory(sessionID)...))
cancel()
if err != nil {
- return false, fmt.Errorf("failed to execute reasonAct
prompt: %w", err)
+ return usage, fmt.Errorf("failed to execute react
prompt: %w", err)
}
- s.addUsage(resp.Usage)
-
- toolReqs := resp.ToolRequests()
- runtime.GetLogger().Info("tool requests:", "req", toolReqs)
-
- // No tools needed: the model answered directly. Record its
reasoning so
- // the observe stage can build on it, and leave tool outputs
empty.
- if len(toolReqs) == 0 {
- if text := resp.Text(); text != "" {
- history.AddHistory(sessionID,
ai.NewMessage(ai.RoleModel, nil, ai.NewTextPart(text)))
- }
- s.Tools = &schema.ToolOutputs{UsageInfo:
&ai.GenerationUsage{}}
- return false, nil
- }
-
- var parts []*ai.Part
- actOuts := &schema.ToolOutputs{UsageInfo: &ai.GenerationUsage{}}
- for _, req := range toolReqs {
- // Each tool runs under its own timeout (per-tool
override, else the
- // shared default), independent of the model call's
budget above.
- tctx, cancel := withTimeout(ctx,
ra.toolTimeouts.For(req.Name))
- output, err := toolEngine.Call(tctx, ra.registry,
req.Name, req.Input)
- cancel()
- if err != nil {
- // Degrade instead of aborting: record the
failure as a tool output
- // so the observe stage can still compose an
answer (or explain the
- // gap) from whatever other tools returned.
- runtime.GetLogger().Warn("tool call failed,
continuing with degraded context",
- "tool", req.Name, "error", err)
- output = toolEngine.ToolOutput{
- ToolName: req.Name,
- Summary: fmt.Sprintf("tool %q failed:
%v", req.Name, err),
+ schema.AccumulateUsage(usage, resp.Usage)
+
+ if !forceAnswer {
+ if reqs := resp.ToolRequests(); len(reqs) > 0 {
+ runtime.GetLogger().Debug("react: model
requested tools", "count", len(reqs))
+ agent.EmitProgress(chans, "🔍 分析问题并调用工具中...\n")
+ if err := ra.execTools(ctx, history, sessionID,
reqs); err != nil {
+ return usage, err
}
+ continue
}
- outputJson, err := json.Marshal(output)
- if err != nil {
- return false, fmt.Errorf("failed to marshal
output: %w", err)
- }
- parts = append(parts,
ai.NewJSONPart(string(outputJson)))
- actOuts.Add(&output)
}
- runtime.GetLogger().Info("act out:", "out", actOuts)
- // ai.RoleTool's messages will be ignored by ai.WithMessages
- history.AddHistory(sessionID, ai.NewMessage(ai.RoleModel, nil,
parts...))
- s.Tools = actOuts
- return false, nil
+
+ ra.finish(chans, history, sessionID, resp.Text())
+ return usage, nil
Review Comment:
A successful model response with neither tool requests nor text is treated
as a completed answer here. `finish` deliberately emits no content when `answer
== ""`, so both an early tool-free response and the forced final iteration can
end with only stream markers, contrary to the promised real reply. Retry empty
responses while budget remains and handle an empty forced answer with an
explicit fallback or error.
##########
ai/component/agent/react/steps.go:
##########
@@ -34,248 +32,123 @@ import (
"github.com/firebase/genkit/go/ai"
)
-// buildSteps materializes the step closures for one interaction, binding the
-// per-interaction channels so progress/streaming reaches the right consumer.
-func (ra *ReActAgent) buildSteps(chans *agent.Channels) []step {
- steps := make([]step, 0, len(ra.stages))
- for _, st := range ra.stages {
- switch st.kind {
- case flowReasonAct:
- steps = append(steps, ra.reasonActStep(st.prompt,
chans, st.timeout))
- case flowObserve:
- steps = append(steps, ra.observeStep(st.prompt, chans,
st.timeout))
- }
- }
- return steps
-}
-
-// historyFromCtx pulls the session-scoped history out of ctx, replacing the
-// pointer/value assertion churn the old flows repeated at every stage.
-func historyFromCtx(ctx context.Context) (*memory.HistoryMemory, string,
error) {
- history, ok := ctx.Value(memory.ChatHistoryKey).(*memory.HistoryMemory)
- if !ok {
- return nil, "", fmt.Errorf("failed to get history from context")
- }
- sessionID, ok := ctx.Value(memory.SessionIDKey).(string)
- if !ok || sessionID == "" {
- return nil, "", fmt.Errorf("session id not found in context")
- }
- return history, sessionID, nil
-}
-
-// reasonActStep merges the old think + act stages: one model call reasons
about
-// the request and, via native function calling, either issues tool requests
-// (which it executes) or issues none (answering directly). The observe stage
-// then composes the reply, so this step never terminates the loop.
-func (ra *ReActAgent) reasonActStep(prompt ai.Prompt, chans *agent.Channels,
timeout time.Duration) step {
- return func(ctx context.Context, s *state) (bool, error) {
- emitStageProgress(chans, flowReasonAct, true)
- defer emitStageProgress(chans, flowReasonAct, false)
-
- history, sessionID, err := historyFromCtx(ctx)
- if err != nil {
- return false, err
- }
- if history.IsEmpty(sessionID) {
- return false, fmt.Errorf("history is empty")
- }
- messages, err := injectCurrentPageContext(ctx,
history.WindowMemory(sessionID))
- if err != nil {
- return false, err
+// run drives the reason-and-act loop for one interaction. Each iteration is a
+// single model call: with native function calling the model either requests
+// tools (whose results are fed back as context for the next iteration) or
+// answers directly — a tool-free response IS the final answer, so no separate
+// "observe" reasoning step is needed to decide when to stop. The last allowed
+// iteration uses the tool-less answer prompt so the loop always terminates
with
+// a real answer rather than an exhausted-budget silence.
+//
+// run streams the answer itself and returns the interaction's accumulated
token
+// usage; the caller emits the final usage marker and closes the channels.
+func (ra *ReActAgent) run(ctx context.Context, chans *agent.Channels)
(*ai.GenerationUsage, error) {
+ history, sessionID, err := historyFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if history.IsEmpty(sessionID) {
+ return nil, fmt.Errorf("history is empty")
+ }
+
+ usage := &ai.GenerationUsage{}
+ for i := 0; i < ra.maxIterations; i++ {
+ // The final iteration must answer: drop the tools so the model
can only
+ // synthesize from what it has already gathered.
+ forceAnswer := i == ra.maxIterations-1
+ prompt := ra.actPrompt
+ if forceAnswer {
+ prompt = ra.answerPrompt
Review Comment:
The core guarantee that the last iteration uses the tool-less answer prompt
is not actually covered: `scriptPrompt` assigns the same instance to
`actPrompt` and `answerPrompt`, so the tests pass even if this branch selects
the wrong prompt. Use distinct prompt spies and assert that the final call
reaches only the answer prompt after the tool-round budget is consumed.
##########
ai/component/server/engine/docs/openapi.yaml:
##########
@@ -23,30 +23,10 @@ paths:
application/json:
schema:
type: object
- required:
- - message
- - sessionID
- properties:
- message:
- type: string
- sessionID:
- type: string
- context:
- $ref: "#/components/schemas/AIContextSnapshot"
- additionalProperties: false
+ properties: {}
Review Comment:
The request schema now documents an unconstrained empty object even though
`StreamChat` still requires `message` and `sessionID`, and the example
immediately below uses both. Generated clients and validators will therefore
omit required inputs. Keep those two required properties in the OpenAPI schema
even if `context` is intentionally removed.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]