Skip to content

Commit a4b0fcc

Browse files
committed
feat: move App Studio workflows into Eino
1 parent 920c7b3 commit a4b0fcc

25 files changed

Lines changed: 1968 additions & 76 deletions

Makefile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -935,7 +935,9 @@ run-provider-app-studio: build-app-studio-provider app-studio-db-up ## Run the A
935935

936936
## Apply the App Studio CatalogEntry into root:kedge:providers. Idempotent.
937937
## Renders only the CatalogEntry from the Helm chart so host-cluster objects
938-
## from the full chart never touch the KCP workspace.
938+
## from the full chart never touch the KCP workspace. The chart normally renders
939+
## the CatalogEntry into a ConfigMap for in-cluster init self-registration; dev
940+
## registration renders the CatalogEntry directly for kcp.
939941
install-provider-app-studio: ## Apply App Studio CatalogEntry into root:kedge:providers
940942
@test -f $(APP_STUDIO_KCP_KUBECONFIG) || { \
941943
echo "kubeconfig not found at $(APP_STUDIO_KCP_KUBECONFIG)"; \
@@ -946,6 +948,7 @@ install-provider-app-studio: ## Apply App Studio CatalogEntry into root:kedge:pr
946948
--namespace $(APP_STUDIO_HELM_NAMESPACE) \
947949
--set-string catalogEntry.uiURL=$(APP_STUDIO_CATALOGENTRY_UI_URL) \
948950
--set-string catalogEntry.backendURL=$(APP_STUDIO_CATALOGENTRY_BACKEND_URL) \
951+
--set catalogEntry.renderAsConfigMap=false \
949952
--show-only templates/catalogentry.yaml \
950953
> $(APP_STUDIO_CATALOGENTRY_RENDERED)
951954
kubectl --kubeconfig=$(APP_STUDIO_KCP_KUBECONFIG) \
@@ -992,6 +995,7 @@ uninstall-provider-app-studio: ## Delete App Studio CatalogEntry
992995
--namespace $(APP_STUDIO_HELM_NAMESPACE) \
993996
--set-string catalogEntry.uiURL=$(APP_STUDIO_CATALOGENTRY_UI_URL) \
994997
--set-string catalogEntry.backendURL=$(APP_STUDIO_CATALOGENTRY_BACKEND_URL) \
998+
--set catalogEntry.renderAsConfigMap=false \
995999
--show-only templates/catalogentry.yaml \
9961000
> $(APP_STUDIO_CATALOGENTRY_RENDERED)
9971001
-kubectl --kubeconfig=$(APP_STUDIO_KCP_KUBECONFIG) \

providers/app-studio/README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,5 +94,8 @@ tool.
9494

9595
App Studio does not run build, test, preview, or log commands inside the
9696
provider pod. The assistant can recommend build and test checks from project
97-
context, but App Studio no longer advertises runtime execution tools until a
98-
tenant-isolated worker implementation is productized.
97+
context. Runtime deployment, status, and preview URL requests are exposed as
98+
App Studio graph workflow tools so the model gets structured handoff results and
99+
blockers. Until a tenant-isolated `RuntimeTarget` implementation is productized,
100+
those workflows return a clear not-configured result instead of executing
101+
provider-pod commands.

providers/app-studio/api/assistant_checkpoint.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ type projectAssistantCheckpointState struct {
4343
RepeatedToolLoop bool `json:"repeatedToolLoop,omitempty"`
4444
LastToolMessages []chatMessage `json:"lastToolMessages,omitempty"`
4545
ApprovedPlan *projectAssistantApprovedPlan `json:"approvedPlan,omitempty"`
46+
SessionSnapshot *projectEinoAssistantSessionSnapshot `json:"sessionSnapshot,omitempty"`
4647
Eino *projectAssistantEinoCheckpointState `json:"eino,omitempty"`
4748
}
4849

@@ -300,7 +301,7 @@ func projectAssistantPermissionReason(spec projectAssistantToolSpec) string {
300301
case projectAssistantToolRiskCommit:
301302
return "This action will commit App Studio workspace changes to the linked repository."
302303
case projectAssistantToolRiskRuntime:
303-
return "This action will start a sandboxed App Studio runtime command."
304+
return "This action will request an App Studio runtime deployment handoff."
304305
default:
305306
return "This action requires approval."
306307
}

providers/app-studio/api/assistant_eino_callbacks.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,16 @@ import (
3232

3333
func projectEinoAssistantRunOptions(req projectAssistantRunRequest, runState *projectEinoAssistantRunState) []adk.AgentRunOption {
3434
handler := newProjectEinoAssistantModelCallbackHandler(req.StreamCallbacks, runState)
35-
if handler == nil {
36-
return nil
35+
opts := []adk.AgentRunOption{}
36+
if handler != nil {
37+
opts = append(opts, adk.WithCallbacks(handler))
38+
}
39+
if snapshot := runState.SessionSnapshot(); snapshot != nil {
40+
opts = append(opts, adk.WithSessionValues(map[string]any{
41+
projectEinoAssistantSessionSnapshotKey: *snapshot,
42+
}))
3743
}
38-
return []adk.AgentRunOption{adk.WithCallbacks(handler)}
44+
return opts
3945
}
4046

4147
func newProjectEinoAssistantModelCallbackHandler(streamCallbacks projectAssistantStreamCallbacks, runState *projectEinoAssistantRunState) callbacks.Handler {
@@ -110,9 +116,6 @@ func (r *projectEinoAssistantModelCallbackRecorder) recordModelStream(output *sc
110116
msg := modelOutput.Message
111117
if msg.Content != "" {
112118
content.WriteString(msg.Content)
113-
if r.streamCallbacks.OnChunk != nil {
114-
r.streamCallbacks.OnChunk(msg.Content)
115-
}
116119
}
117120
if len(msg.ToolCalls) > 0 {
118121
r.reportToolPreparation()

providers/app-studio/api/assistant_eino_callbacks_test.go

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ func TestProjectEinoAssistantModelCallbackRecordsStreamedToolCalls(t *testing.T)
7373
handler.OnEndWithStreamOutput(ctx, nil, stream)
7474

7575
state := runState.CheckpointState()
76-
if len(chunks) != 1 || chunks[0] != "Working " {
77-
t.Fatalf("chunks = %#v, want streamed content chunk", chunks)
76+
if len(chunks) != 0 {
77+
t.Fatalf("chunks = %#v, want model callback to avoid publishing public content chunks", chunks)
7878
}
7979
if len(statuses) != 1 || statuses[0] != "Preparing action" {
8080
t.Fatalf("statuses = %#v, want one preparation status", statuses)
@@ -90,3 +90,27 @@ func TestProjectEinoAssistantModelCallbackRecordsStreamedToolCalls(t *testing.T)
9090
t.Fatalf("tool call = %#v, want merged streamed function call", call)
9191
}
9292
}
93+
94+
func TestProjectEinoAssistantModelCallbackDoesNotPublishPublicContentChunks(t *testing.T) {
95+
runState := newProjectEinoAssistantRunState()
96+
var chunks []string
97+
handler := newProjectEinoAssistantModelCallbackHandler(projectAssistantStreamCallbacks{
98+
OnChunk: func(chunk string) { chunks = append(chunks, chunk) },
99+
}, runState)
100+
101+
ctx := handler.OnStart(context.Background(), nil, &einomodel.CallbackInput{
102+
Messages: []*schema.Message{schema.UserMessage("say thanks")},
103+
})
104+
stream := schema.StreamReaderFromArray([]callbacks.CallbackOutput{
105+
&einomodel.CallbackOutput{Message: schema.AssistantMessage("You are welcome.", nil)},
106+
})
107+
handler.OnEndWithStreamOutput(ctx, nil, stream)
108+
109+
if len(chunks) != 0 {
110+
t.Fatalf("chunks = %#v, want model callback to avoid publishing public content chunks", chunks)
111+
}
112+
state := runState.CheckpointState()
113+
if len(state.Messages) != 2 || state.Messages[1].Content != "You are welcome." {
114+
t.Fatalf("checkpoint messages = %#v, want streamed assistant reply recorded", state.Messages)
115+
}
116+
}

providers/app-studio/api/assistant_eino_engine.go

Lines changed: 120 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"context"
2121
"errors"
2222
"fmt"
23+
"io"
2324
"strings"
2425

2526
"github.com/cloudwego/eino/adk"
@@ -35,6 +36,10 @@ const (
3536
projectEinoAssistantSummaryContextMessages = 128
3637
projectEinoAssistantSummaryContextTokens = 24000
3738
projectEinoAssistantSummaryInstruction = "Summarize this App Studio project session for the next builder turn. Preserve user requirements, accepted plans, files touched or inspected, unresolved questions, repository/runtime state, and any constraints. Keep it concise and operational."
39+
40+
// Bundle search is for App Studio's full product toolbox. Smaller injected
41+
// tool sets stay direct so focused permission/resume flows keep their shape.
42+
projectEinoAssistantBundleSearchMinTools = 4
3843
)
3944

4045
type projectEinoAssistantEngine struct {
@@ -181,6 +186,8 @@ func (e projectEinoAssistantEngine) newAgent(ctx context.Context, req projectAss
181186
}
182187

183188
func projectEinoAssistantToolSearchSets(ctx context.Context, tools []einotool.BaseTool) ([]einotool.BaseTool, []einotool.BaseTool, error) {
189+
infos := make([]*schema.ToolInfo, 0, len(tools))
190+
searchCandidateCount := 0
184191
staticTools := make([]einotool.BaseTool, 0, len(tools))
185192
dynamicTools := make([]einotool.BaseTool, 0, len(tools))
186193
for _, tool := range tools {
@@ -191,7 +198,20 @@ func projectEinoAssistantToolSearchSets(ctx context.Context, tools []einotool.Ba
191198
if err != nil {
192199
return nil, nil, err
193200
}
194-
if projectEinoAssistantToolUsesSearch(info) {
201+
infos = append(infos, info)
202+
if projectEinoAssistantToolCanUseSearch(info) {
203+
searchCandidateCount++
204+
}
205+
}
206+
useBundleSearch := searchCandidateCount >= projectEinoAssistantBundleSearchMinTools
207+
infoIndex := 0
208+
for _, tool := range tools {
209+
if tool == nil {
210+
continue
211+
}
212+
info := infos[infoIndex]
213+
infoIndex++
214+
if projectEinoAssistantToolUsesSearch(info, useBundleSearch) {
195215
dynamicTools = append(dynamicTools, tool)
196216
continue
197217
}
@@ -200,10 +220,21 @@ func projectEinoAssistantToolSearchSets(ctx context.Context, tools []einotool.Ba
200220
return staticTools, dynamicTools, nil
201221
}
202222

203-
func projectEinoAssistantToolUsesSearch(info *schema.ToolInfo) bool {
223+
func projectEinoAssistantToolCanUseSearch(info *schema.ToolInfo) bool {
204224
if info == nil || info.Extra == nil {
205225
return false
206226
}
227+
bundle, _ := info.Extra["bundle"].(string)
228+
return projectAssistantToolBundle(bundle) != projectAssistantToolBundleCollaboration
229+
}
230+
231+
func projectEinoAssistantToolUsesSearch(info *schema.ToolInfo, useBundleSearch bool) bool {
232+
if info == nil || info.Extra == nil {
233+
return false
234+
}
235+
if useBundleSearch && projectEinoAssistantToolCanUseSearch(info) {
236+
return true
237+
}
207238
risk, _ := info.Extra["risk"].(string)
208239
return projectAssistantToolRisk(risk) == projectAssistantToolRiskRead
209240
}
@@ -231,7 +262,7 @@ func (e projectEinoAssistantEngine) runProjectAssistantTurnLoop(
231262
if len(items) == 0 {
232263
return nil, errors.New("eino turn loop received no work")
233264
}
234-
input, err := projectEinoAssistantInputMessages(req, runState)
265+
input, err := projectEinoAssistantInputMessages(loopCtx, req, runState)
235266
if err != nil {
236267
return nil, err
237268
}
@@ -376,14 +407,19 @@ func (e projectEinoAssistantEngine) collectProjectAssistantTurnEvents(
376407
outcome.receivedOutput = true
377408
continue
378409
}
379-
if event.Output.MessageOutput == nil {
410+
messageOutput := event.Output.MessageOutput
411+
if messageOutput == nil {
380412
continue
381413
}
382-
msg, err := event.Output.MessageOutput.GetMessage()
414+
msg, err := projectEinoAssistantMessageOutput(eventCtx, messageOutput, req.StreamCallbacks)
383415
if err != nil {
384416
return err
385417
}
386-
if msg != nil && msg.Role == schema.Assistant && strings.TrimSpace(msg.Content) != "" {
418+
role := messageOutput.Role
419+
if role == "" && msg != nil {
420+
role = msg.Role
421+
}
422+
if msg != nil && role == schema.Assistant && strings.TrimSpace(msg.Content) != "" && !projectEinoAssistantMessageSuppressesPublicContent(msg) {
387423
outcome.result.Content = msg.Content
388424
outcome.receivedOutput = true
389425
}
@@ -394,6 +430,80 @@ func (e projectEinoAssistantEngine) collectProjectAssistantTurnEvents(
394430
return nil
395431
}
396432

433+
func projectEinoAssistantMessageOutput(
434+
ctx context.Context,
435+
output *adk.TypedMessageVariant[*schema.Message],
436+
streamCallbacks projectAssistantStreamCallbacks,
437+
) (*schema.Message, error) {
438+
if output == nil {
439+
return nil, nil
440+
}
441+
if !output.IsStreaming {
442+
return output.Message, nil
443+
}
444+
if output.MessageStream == nil {
445+
return nil, errors.New("eino assistant stream event missing message stream")
446+
}
447+
defer output.MessageStream.Close()
448+
449+
var chunks []*schema.Message
450+
for {
451+
if err := ctx.Err(); err != nil {
452+
return nil, err
453+
}
454+
msg, err := output.MessageStream.Recv()
455+
if errors.Is(err, io.EOF) {
456+
break
457+
}
458+
if err != nil {
459+
return nil, err
460+
}
461+
if msg == nil {
462+
continue
463+
}
464+
chunks = append(chunks, msg)
465+
}
466+
msg, err := schema.ConcatMessages(chunks)
467+
if err != nil {
468+
return nil, err
469+
}
470+
if output.Role == schema.Assistant && !projectEinoAssistantMessageSuppressesPublicContent(msg) && streamCallbacks.OnChunk != nil {
471+
for _, chunk := range chunks {
472+
if chunk != nil && chunk.Content != "" {
473+
streamCallbacks.OnChunk(chunk.Content)
474+
}
475+
}
476+
}
477+
return msg, nil
478+
}
479+
480+
func projectEinoAssistantMessageHasToolCalls(msg *schema.Message) bool {
481+
return msg != nil && len(msg.ToolCalls) > 0
482+
}
483+
484+
func projectEinoAssistantMessageSuppressesPublicContent(msg *schema.Message) bool {
485+
return projectEinoAssistantMessageHasToolCalls(msg) && projectEinoAssistantToolNarration(msg.Content)
486+
}
487+
488+
func projectEinoAssistantToolNarration(content string) bool {
489+
content = strings.TrimSpace(strings.ToLower(content))
490+
if content == "" {
491+
return false
492+
}
493+
for _, prefix := range []string{
494+
"i will ",
495+
"i'll ",
496+
"i am going to ",
497+
"i'm going to ",
498+
"let me ",
499+
} {
500+
if strings.HasPrefix(content, prefix) {
501+
return true
502+
}
503+
}
504+
return false
505+
}
506+
397507
func (e projectEinoAssistantEngine) projectAssistantToolLoopFinalAnswer(
398508
ctx context.Context,
399509
req projectAssistantRunRequest,
@@ -571,12 +681,15 @@ func projectEinoFollowUpInterruptInfoFromEvent(interrupted *adk.InterruptInfo) (
571681
return nil, "", false
572682
}
573683

574-
func projectEinoAssistantInputMessages(req projectAssistantRunRequest, runState *projectEinoAssistantRunState) ([]adk.Message, error) {
684+
func projectEinoAssistantInputMessages(ctx context.Context, req projectAssistantRunRequest, runState *projectEinoAssistantRunState) ([]adk.Message, error) {
575685
var chatMessages []chatMessage
576686
if req.Continuation != nil && len(req.Continuation.Messages) > 0 {
577687
chatMessages = cloneChatMessages(req.Continuation.Messages)
578688
} else {
579689
chatMessages = projectPromptMessages(req.Project, req.Repository, req.History)
690+
if snapshot, ok := projectEinoAssistantSessionContextMessage(ctx, req, runState); ok {
691+
chatMessages = append(chatMessages, snapshot)
692+
}
580693
if prompt := runState.ToolPrompt(); prompt != "" {
581694
chatMessages = append(chatMessages, chatMessage{Role: "system", Content: prompt})
582695
}

0 commit comments

Comments
 (0)